mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
ae15f3cdc5
* refactor: align header columns * refactor: improve scrollbar visibility * refactor: center align table elements * refactor: make param elements stateful * fix: issue with collapsed elements not loosing value * fix: prevent search params containing multiple alias references * fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744) * refactor: disable group action for elements in groups * refactor: move context menu items into the event element (#1747) * feat: sheet import new features for v4 (#1730) * import milestone * fixup! import milestone * test: milestone import * add entries to group stop on new group or on group-end type * fixup! add entries to group * cleanup * add event target duration * link start if undefined * add skip import type * extract some to the excel paresing functions * tweaks to presentation * move file --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * fix: notify runtimeStore of events bieng groupd * fix: improve authentication and stage detection in demo * chore: ship logo with project * fix: client is referenced by name * fix: prevent reflow in event editor * fix: stale render on selected event due to ref mismatch * Create/Load/Delete multiple rundowns (#1696) * refactor: restore last loaded rundown * refactor: initialise rundown in ProjectService * feat: allow switching rundowns ensure on coordination between the db object and the working object server provide list of rundowns implement switch in the UI implement delete implement new rundown button * fix: render order for floating button * refactor: appropriate names to service * refactor: rundown endpoints * refactor: save last loaded rundown ID * refactor: rundown management UI * refactor: emit refetch all on project load --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * feat: recover single event subscription * fixup! feat: sheet import new features for v4 (#1730) * fix: prevent dropping a group inside another * fixup! refactor: move context menu items into the event element (#1747) * fix: prevent stale references to custom fields * fix: propagate updates to all rundowns * refactor: client rundown metadata (#1728) * generate metadata in the hook * move test * ensure there is always a last element * use for-loop * update metadata in useEfect * fully extract metadata generation * use direct assignment * cleanup --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * refactor: small imporvement and tests for coerce functions (#1752) * refactor: small imporvement and tests for coerce functions add test `coerceString` add test `coerceBoolean` add test `coerceColour` * remove old todo * fix: consistent quick add behaviour * refactor: create flat rundown with metadata * fix: show add buttons on top * feat: allow editing milestones * refactor: style tweaks to rundown elements refactor: milestones are full width refactor: cuesheet header alignment fix: editor styling in cuesheet * refactor: virtualise table * refactor: improve overscan (#1758) * bump version to 4.0.0-alpha.5 --------- Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
|
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
|
|
|
import { copyFile } from 'fs/promises';
|
|
|
|
import { logger } from '../../classes/Logger.js';
|
|
import { publicDir } from '../../setup/index.js';
|
|
import {
|
|
appendToName,
|
|
deleteFile,
|
|
dockerSafeRename,
|
|
ensureDirectory,
|
|
ensureJsonExtension,
|
|
generateUniqueFileName,
|
|
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 { 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';
|
|
import { join } from 'path';
|
|
|
|
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> {
|
|
return createProject(config.demoProject, demoDb);
|
|
}
|
|
|
|
/**
|
|
* Private function loads a new, empty project
|
|
* to be composed in the loading functions
|
|
*/
|
|
async function loadNewProject(): Promise<string> {
|
|
return createProject(config.newProject, dbModel);
|
|
}
|
|
|
|
/**
|
|
* Private function handles side effects on corrupted files
|
|
* Corrupted files in this context contain data that failed domain validation
|
|
* @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);
|
|
}
|
|
|
|
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, lastLoaded.rundownId);
|
|
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
|
|
* @param fileName file name of the project including the extension
|
|
*/
|
|
export async function loadProjectFile(fileName: string, rundownId?: string): 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);
|
|
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, 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);
|
|
return copyFile(projectFilePath, pathToDuplicate);
|
|
}
|
|
|
|
/**
|
|
* Renames an existing project file
|
|
*/
|
|
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: Partial<DatabaseModel>): Promise<string> {
|
|
const data = safeMerge(dbModel, initialData);
|
|
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(fileName));
|
|
await loadProject(data, fileNameWithExtension);
|
|
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);
|
|
|
|
// ... but rundown and custom fields need to be checked
|
|
if (rundowns != null) {
|
|
const customFields = parseCustomFields(data);
|
|
const result = parseRundowns(data, customFields);
|
|
|
|
/**
|
|
* The user may have multiple rundowns
|
|
* We currently ignore all other rundowns
|
|
*/
|
|
const firstRundown = getFirstRundown(result);
|
|
|
|
await initRundown(firstRundown, customFields);
|
|
}
|
|
|
|
const updatedData = await getDataProvider().getData();
|
|
return updatedData;
|
|
}
|