mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-16 04:43:35 +00:00
refactor: migrate project upload (#796)
* refactor: migrate project upload
This commit is contained in:
+11
-11
@@ -7,16 +7,16 @@ import http, { type Server } from 'http';
|
||||
import cors from 'cors';
|
||||
|
||||
// import utils
|
||||
import { join, resolve } from 'path';
|
||||
import { resolve } from 'path';
|
||||
import {
|
||||
currentDirectory,
|
||||
srcDirectory,
|
||||
environment,
|
||||
isProduction,
|
||||
resolveDbPath,
|
||||
resolveExternalsDirectory,
|
||||
resolveStylesDirectory,
|
||||
resolvedPath,
|
||||
} from './setup.js';
|
||||
} from './setup/index.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
|
||||
// Import Routes
|
||||
@@ -29,19 +29,19 @@ import { router as apiRouter } from './routes/apiRouter.js';
|
||||
import { OscServer } from './adapters/OscAdapter.js';
|
||||
import { socket } from './adapters/WebsocketAdapter.js';
|
||||
import { DataProvider } from './classes/data-provider/DataProvider.js';
|
||||
import { dbLoadingProcess } from './modules/loadDb.js';
|
||||
import { dbLoadingProcess } from './setup/loadDb.js';
|
||||
|
||||
// Services
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from './services/integration-service/HttpIntegration.js';
|
||||
import { populateStyles } from './modules/loadStyles.js';
|
||||
import { populateStyles } from './setup/loadStyles.js';
|
||||
import { eventStore } from './stores/EventStore.js';
|
||||
import { runtimeService } from './services/runtime-service/RuntimeService.js';
|
||||
import { restoreService } from './services/RestoreService.js';
|
||||
import { messageService } from './services/message-service/MessageService.js';
|
||||
import { populateDemo } from './modules/loadDemo.js';
|
||||
import { populateDemo } from './setup/loadDemo.js';
|
||||
import { getState, updateRundownData } from './stores/runtimeState.js';
|
||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||
import { getPlayableEvents } from './services/rundown-service/rundownUtils.js';
|
||||
@@ -51,7 +51,7 @@ console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Ontime running in ${environment} environment`);
|
||||
console.log(`Ontime directory at ${currentDirectory} `);
|
||||
console.log(`Ontime directory at ${srcDirectory} `);
|
||||
console.log(`Ontime database at ${resolveDbPath}`);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ app.use('/external', (req, res) => {
|
||||
});
|
||||
|
||||
// serve static - react, in dev/test mode we fetch the React app from module
|
||||
const reactAppPath = join(currentDirectory, resolvedPath());
|
||||
const reactAppPath = resolvedPath();
|
||||
app.use(
|
||||
expressStaticGzip(reactAppPath, {
|
||||
enableBrotli: true,
|
||||
@@ -91,12 +91,12 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(resolve(currentDirectory, resolvedPath(), 'index.html'));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(resolve(resolvedPath(), 'index.html'));
|
||||
});
|
||||
|
||||
// Implement catch all
|
||||
app.use((error, response) => {
|
||||
app.use((_error, response) => {
|
||||
response.status(400).send('Unhandled request');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Log, LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { isProduction } from '../setup.js';
|
||||
import { isProduction } from '../setup/index.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
|
||||
class Logger {
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
HttpSettings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { data, db } from '../../setup/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
import { isTest } from '../../setup.js';
|
||||
import { isTest } from '../../setup/index.js';
|
||||
|
||||
export class DataProvider {
|
||||
static getData() {
|
||||
@@ -48,7 +48,7 @@ export class DataProvider {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static getSettings() {
|
||||
static getSettings(): Settings {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export class DataProvider {
|
||||
return data.http;
|
||||
}
|
||||
|
||||
static getAliases() {
|
||||
static getAliases(): Alias[] {
|
||||
return data.aliases;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
export const config = {
|
||||
database: {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
},
|
||||
styles: {
|
||||
directory: 'styles',
|
||||
filename: 'override.css',
|
||||
},
|
||||
demo: {
|
||||
directory: 'demo',
|
||||
filename: ['app.js', 'index.html', 'styles.css'],
|
||||
},
|
||||
sheets: {
|
||||
directory: 'sheets',
|
||||
},
|
||||
restoreFile: 'ontime.restore',
|
||||
};
|
||||
|
||||
export const timerConfig = {
|
||||
skipLimit: 1000, // threshold of skip for recalculating
|
||||
updateRate: 32, // how often do we update the timer
|
||||
|
||||
@@ -7,44 +7,35 @@ import type {
|
||||
ErrorResponse,
|
||||
ProjectFileListResponse,
|
||||
OSCSettings,
|
||||
RuntimeStore,
|
||||
Settings,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { ImportOptions } from 'ontime-utils';
|
||||
|
||||
import { RequestHandler, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { join } from 'path';
|
||||
import { copyFile, rename, writeFile } from 'fs/promises';
|
||||
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import {
|
||||
getAppDataPath,
|
||||
isDocker,
|
||||
lastLoadedProjectConfigPath,
|
||||
resolveDbPath,
|
||||
resolveStylesPath,
|
||||
uploadsFolderPath,
|
||||
} from '../setup.js';
|
||||
import { isDocker, resolveDbPath, resolveProjectsDirectory, uploadsFolderPath } from '../setup/index.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
||||
import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
|
||||
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
|
||||
import { configService } from '../services/ConfigService.js';
|
||||
import { deleteFile } from '../utils/parserUtils.js';
|
||||
import { validateProjectFiles } from './ontimeController.validate.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { removeFileExtension } from '../utils/removeFileExtension.js';
|
||||
import { setRundown } from '../services/rundown-service/RundownService.js';
|
||||
import { appStateService } from '../services/app-state-service/AppStateService.js';
|
||||
import type { OntimeError } from '../utils/backend.types.js';
|
||||
import { ensureJsonExtension } from '../utils/ensureJsonExtension.js';
|
||||
import { generateUniqueFileName } from '../utils/generateUniqueFilename.js';
|
||||
|
||||
import * as projectService from '../services/project-service/ProjectService.js';
|
||||
import { extractPin } from '../services/project-service/ProjectService.js';
|
||||
import { handleMaybeExcel } from '../utils/parser.js';
|
||||
import { ensureJsonExtension } from '../utils/fileManagement.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
export const poll = async (_req: Request, res: Response) => {
|
||||
export const poll = async (_req: Request, res: Response<Partial<RuntimeStore> | ErrorResponse>) => {
|
||||
try {
|
||||
const state = eventStore.poll();
|
||||
res.status(200).send(state);
|
||||
@@ -70,103 +61,23 @@ export const dbDownload = async (_req: Request, res: Response) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a file and returns the result objects
|
||||
* @param filePath
|
||||
* @param _req
|
||||
* @param _res
|
||||
* @param options
|
||||
*/
|
||||
async function parseFile(filePath: string, _req: Request, _res: Response, options: ImportOptions) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
const result = await fileHandler(filePath, options);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export type ParsingOptions = {
|
||||
onlyRundown?: 'true' | 'false';
|
||||
};
|
||||
|
||||
/**
|
||||
* parse an uploaded file and apply its parsed objects
|
||||
* @param file
|
||||
* @param _req
|
||||
* @param res
|
||||
* @param [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const parseAndApply = async (file, _req: Request, res: Response, options) => {
|
||||
const result = await parseFile(file, _req, res, options);
|
||||
|
||||
runtimeService.stop();
|
||||
|
||||
const newRundown = result.rundown || [];
|
||||
const { rundown, ...rest } = result;
|
||||
if (options?.onlyRundown === 'true') {
|
||||
setRundown(newRundown ?? []);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(rest);
|
||||
setRundown(rundown ?? []);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Gets information on IPV4 non-internal interfaces
|
||||
* @returns {array} - Array of objects {name: ip}
|
||||
*/
|
||||
const getNetworkInterfaces = () => {
|
||||
const nets = networkInterfaces();
|
||||
const results: { name: string; address: string }[] = [];
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]) {
|
||||
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
results.push({
|
||||
name,
|
||||
address: net.address,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/ontime/info'
|
||||
// Returns -
|
||||
export const getInfo = async (_req: Request, res: Response<GetInfo>) => {
|
||||
const { version, serverPort } = DataProvider.getSettings();
|
||||
const osc = DataProvider.getOsc();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
||||
const cssOverride = resolveStylesPath;
|
||||
|
||||
// send object with network information
|
||||
res.status(200).send({
|
||||
networkInterfaces: ni,
|
||||
version,
|
||||
serverPort,
|
||||
osc,
|
||||
cssOverride,
|
||||
});
|
||||
const info = await projectService.getInfo();
|
||||
res.status(200).send(info);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/aliases'
|
||||
// Create controller for GET request to '/ontime/aliases'
|
||||
// Returns -
|
||||
export const getAliases = async (_req: Request, res: Response) => {
|
||||
export const getAliases = async (_req: Request, res: Response<Alias[]>) => {
|
||||
const aliases = DataProvider.getAliases();
|
||||
res.status(200).send(aliases);
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/aliases'
|
||||
// Returns ACK message
|
||||
export const postAliases = async (req: Request, res: Response) => {
|
||||
export const postAliases = async (req: Request, res: Response<Alias[] | ErrorResponse>) => {
|
||||
if (failIsNotArray(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
@@ -186,26 +97,13 @@ export const postAliases = async (req: Request, res: Response) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/settings'
|
||||
// Create controller for GET request to '/ontime/settings'
|
||||
// Returns -
|
||||
export const getSettings = async (_req: Request, res: Response) => {
|
||||
export const getSettings = async (_req: Request, res: Response<Settings>) => {
|
||||
const settings = DataProvider.getSettings();
|
||||
res.status(200).send(settings);
|
||||
};
|
||||
|
||||
function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Create controller for POST request to '/ontime/settings'
|
||||
// Returns ACK message
|
||||
export const postSettings = async (req: Request, res: Response) => {
|
||||
@@ -252,7 +150,7 @@ export const postSettings = async (req: Request, res: Response) => {
|
||||
/**
|
||||
* @description Get view Settings
|
||||
*/
|
||||
export const getViewSettings = async (_req: Request, res: Response) => {
|
||||
export const getViewSettings = async (_req: Request, res: Response<ViewSettings>) => {
|
||||
const views = DataProvider.getViewSettings();
|
||||
res.status(200).send(views);
|
||||
};
|
||||
@@ -260,7 +158,7 @@ export const getViewSettings = async (_req: Request, res: Response) => {
|
||||
/**
|
||||
* @description Change view Settings
|
||||
*/
|
||||
export const postViewSettings = async (req: Request, res: Response) => {
|
||||
export const postViewSettings = async (req: Request, res: Response<ViewSettings | ErrorResponse>) => {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +182,7 @@ export const postViewSettings = async (req: Request, res: Response) => {
|
||||
|
||||
// Create controller for GET request to '/ontime/osc'
|
||||
// Returns -
|
||||
export const getOSC = async (_req: Request, res: Response) => {
|
||||
export const getOSC = async (_req: Request, res: Response<OSCSettings>) => {
|
||||
const osc = DataProvider.getOsc();
|
||||
res.status(200).send(osc);
|
||||
};
|
||||
@@ -365,15 +263,16 @@ export async function patchPartialProjectFile(req: Request, res: Response) {
|
||||
/**
|
||||
* uploads, parses and applies the data from a given file
|
||||
*/
|
||||
export const dbUpload = async (req: Request, res: Response) => {
|
||||
export const uploadProjectFile = async (req: Request, res: Response) => {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
return;
|
||||
}
|
||||
const options = req.query;
|
||||
const file = req.file.path;
|
||||
|
||||
try {
|
||||
await parseAndApply(file, req, res, options);
|
||||
const options = req.query;
|
||||
const filePath = req.file.path;
|
||||
await projectService.applyProjectFile(filePath, options);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
@@ -391,9 +290,13 @@ export async function previewSpreadsheet(req: Request, res: Response) {
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(req.body.options);
|
||||
const filePath = req.file.path;
|
||||
const data = await parseFile(filePath, req, res, options);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const options = JSON.parse(req.body.options);
|
||||
const data = handleMaybeExcel(filePath, options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
@@ -407,16 +310,8 @@ export async function previewSpreadsheet(req: Request, res: Response) {
|
||||
*/
|
||||
export const listProjects: RequestHandler = async (_req, res: Response<ProjectFileListResponse | ErrorResponse>) => {
|
||||
try {
|
||||
const fileList = await getProjectFiles();
|
||||
|
||||
const lastLoadedProject = JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject;
|
||||
|
||||
const lastLoadedProjectName = removeFileExtension(lastLoadedProject);
|
||||
|
||||
res.status(200).send({
|
||||
files: fileList,
|
||||
lastLoadedProject: lastLoadedProjectName,
|
||||
});
|
||||
const data = await projectService.getProjectList();
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: String(error) });
|
||||
}
|
||||
@@ -430,16 +325,12 @@ export const listProjects: RequestHandler = async (_req, res: Response<ProjectFi
|
||||
export const loadProject: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const filename = req.body.filename;
|
||||
|
||||
const uploadsFolderPath = join(getAppDataPath(), 'uploads');
|
||||
const filePath = join(uploadsFolderPath, filename);
|
||||
const filePath = join(resolveProjectsDirectory, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).send({ message: 'File not found' });
|
||||
}
|
||||
|
||||
await parseAndApply(filePath, req, res, {});
|
||||
|
||||
await projectService.applyProjectFile(filePath);
|
||||
res.status(200).send({
|
||||
message: `Loaded project ${filename}`,
|
||||
});
|
||||
@@ -463,16 +354,13 @@ export const duplicateProjectFile: RequestHandler = async (req: Request, res: Re
|
||||
const { filename } = req.params;
|
||||
const { newFilename } = req.body;
|
||||
|
||||
const projectFilePath = join(uploadsFolderPath, filename);
|
||||
const duplicateProjectFilePath = join(uploadsFolderPath, newFilename);
|
||||
|
||||
const errors = validateProjectFiles({ filename, newFilename });
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
await copyFile(projectFilePath, duplicateProjectFilePath);
|
||||
await projectService.duplicateProjectFile(filename, newFilename);
|
||||
|
||||
res.status(200).send({
|
||||
message: `Duplicated project ${filename} to ${newFilename}`,
|
||||
@@ -497,24 +385,14 @@ export const renameProjectFile: RequestHandler = async (req: Request, res: Respo
|
||||
const { newFilename } = req.body;
|
||||
const { filename } = req.params;
|
||||
|
||||
const projectFilePath = join(uploadsFolderPath, filename);
|
||||
const newProjectFilePath = join(uploadsFolderPath, newFilename);
|
||||
|
||||
const errors = validateProjectFiles({ filename, newFilename });
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
// Rename the file
|
||||
await rename(projectFilePath, newProjectFilePath);
|
||||
|
||||
// Update the last loaded project config if current loaded project is the one being renamed
|
||||
const { lastLoadedProject } = await configService.getConfig();
|
||||
|
||||
if (lastLoadedProject === filename) {
|
||||
await configService.updateDatabaseConfig(newFilename);
|
||||
}
|
||||
await projectService.renameProjectFile(filename, newFilename);
|
||||
|
||||
res.status(200).send({
|
||||
message: `Renamed project ${filename} to ${newFilename}`,
|
||||
@@ -537,10 +415,11 @@ export const createProjectFile: RequestHandler = async (req: Request, res: Respo
|
||||
try {
|
||||
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
|
||||
const filename = generateUniqueFileName(uploadsFolderPath, originalFilename);
|
||||
const errors = projectService.validateProjectFiles({ newFilename: filename });
|
||||
|
||||
const projectFilePath = join(uploadsFolderPath, filename);
|
||||
|
||||
const errors = validateProjectFiles({ newFilename: filename });
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: 'Project with title already exists' });
|
||||
}
|
||||
|
||||
const newProjectData: ProjectData = {
|
||||
title: req.body?.title ?? '',
|
||||
@@ -551,20 +430,7 @@ export const createProjectFile: RequestHandler = async (req: Request, res: Respo
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
|
||||
const data = {
|
||||
...dbModel,
|
||||
project: {
|
||||
...dbModel.project,
|
||||
...newProjectData,
|
||||
},
|
||||
};
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: 'Project with title already exists' });
|
||||
}
|
||||
|
||||
await writeFile(projectFilePath, JSON.stringify(data));
|
||||
await parseAndApply(projectFilePath, req, res, {});
|
||||
projectService.createProjectFile(filename, newProjectData);
|
||||
|
||||
res.status(200).send({
|
||||
filename,
|
||||
@@ -588,21 +454,19 @@ export const deleteProjectFile: RequestHandler = async (req: Request, res: Respo
|
||||
try {
|
||||
const { filename } = req.params;
|
||||
|
||||
const { lastLoadedProject } = await configService.getConfig();
|
||||
const { lastLoadedProject } = await appStateService.get();
|
||||
|
||||
if (lastLoadedProject === filename) {
|
||||
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
|
||||
}
|
||||
|
||||
const projectFilePath = join(uploadsFolderPath, filename);
|
||||
|
||||
const errors = validateProjectFiles({ filename });
|
||||
const errors = projectService.validateProjectFiles({ filename });
|
||||
|
||||
if (errors.length) {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
await deleteFile(projectFilePath);
|
||||
await projectService.deleteProjectFile(filename);
|
||||
|
||||
res.status(200).send({
|
||||
message: `Deleted project ${filename}`,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { body, check, validationResult } from 'express-validator';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { uploadsFolderPath } from '../setup.js';
|
||||
|
||||
import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../utils/parserFunctions.js';
|
||||
|
||||
/**
|
||||
@@ -168,34 +166,3 @@ export const validateProjectRename = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the existence of project files.
|
||||
* @param {object} projectFiles
|
||||
* @param {string} projectFiles.projectFilename
|
||||
* @param {string} projectFiles.newFilename
|
||||
*
|
||||
* @returns {Promise<Array<string>>} Array of errors
|
||||
*
|
||||
*/
|
||||
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (projectFiles.filename) {
|
||||
const projectFilePath = join(uploadsFolderPath, projectFiles.filename);
|
||||
|
||||
if (!existsSync(projectFilePath)) {
|
||||
errors.push('Project file does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (projectFiles.newFilename) {
|
||||
const projectFilePath = join(uploadsFolderPath, projectFiles.newFilename);
|
||||
|
||||
if (existsSync(projectFilePath)) {
|
||||
errors.push('New project file already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { isAlphanumeric } from 'ontime-utils';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, param, validationResult } from 'express-validator';
|
||||
|
||||
import { ensureJsonExtension } from '../utils/fileManagement.js';
|
||||
|
||||
export const projectSanitiser = [
|
||||
body('title').optional().isString().trim(),
|
||||
body('description').optional().isString().trim(),
|
||||
@@ -59,3 +61,14 @@ export const validateDeleteCustomField = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
|
||||
const { filename, newFilename } = req.body;
|
||||
const { filename: projectName } = req.params;
|
||||
|
||||
req.body.filename = ensureJsonExtension(filename);
|
||||
req.body.newFilename = ensureJsonExtension(newFilename);
|
||||
req.params.filename = ensureJsonExtension(projectName);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/**
|
||||
* API Router
|
||||
* User to handle all requests which affect runtime
|
||||
* It is a mirror implementation of OSC and Websocket Adapters
|
||||
*
|
||||
*/
|
||||
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import { dispatchFromAdapter } from '../controllers/integrationController.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
import { objectFromPath } from '../adapters/utils/parse.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
@@ -2,7 +2,7 @@ import express from 'express';
|
||||
import { uploadClientSecret, uploadFile } from '../utils/upload.js';
|
||||
import {
|
||||
dbDownload,
|
||||
dbUpload,
|
||||
uploadProjectFile,
|
||||
getAliases,
|
||||
getInfo,
|
||||
getOSC,
|
||||
@@ -36,8 +36,7 @@ import {
|
||||
validateLoadProjectFile,
|
||||
validateProjectRename,
|
||||
} from '../controllers/ontimeController.validate.js';
|
||||
import { projectSanitiser } from '../controllers/projectController.validate.js';
|
||||
import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js';
|
||||
import { projectSanitiser, sanitizeProjectFilename } from '../controllers/projectController.validate.js';
|
||||
import {
|
||||
revokeAuthentication,
|
||||
readFromSheet,
|
||||
@@ -52,11 +51,12 @@ export const router = express.Router();
|
||||
// create route between controller and '/ontime/sync' endpoint
|
||||
router.get('/poll', poll);
|
||||
|
||||
// TODO: should db be the root endpoint for /ontime/data
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.get('/db', dbDownload);
|
||||
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.post('/db', uploadFile, dbUpload);
|
||||
router.post('/db', uploadFile, uploadProjectFile);
|
||||
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MaybeNumber, MaybeString, Playback } from 'ontime-types';
|
||||
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { resolveRestoreFile } from '../setup.js';
|
||||
import { resolveRestoreFile } from '../setup/index.js';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
|
||||
+9
-10
@@ -1,24 +1,23 @@
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { join } from 'path';
|
||||
|
||||
import { getAppDataPath, isTest } from '../setup.js';
|
||||
import { appStatePath, isTest } from '../../setup/index.js';
|
||||
|
||||
interface Config {
|
||||
lastLoadedProject: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service manages Ontime's runtime configuration
|
||||
* Service manages Ontime's runtime memory between boots
|
||||
*/
|
||||
|
||||
class ConfigService {
|
||||
class AppStateService {
|
||||
private config: Low<Config>;
|
||||
private configPath: string;
|
||||
private pathToFile: string;
|
||||
|
||||
constructor() {
|
||||
this.configPath = join(getAppDataPath(), 'config.json');
|
||||
const adapter = new JSONFile<Config>(this.configPath);
|
||||
constructor(appStatePath: string) {
|
||||
this.pathToFile = appStatePath;
|
||||
const adapter = new JSONFile<Config>(this.pathToFile);
|
||||
this.config = new Low<Config>(adapter, null);
|
||||
|
||||
this.init();
|
||||
@@ -29,7 +28,7 @@ class ConfigService {
|
||||
await this.config.write();
|
||||
}
|
||||
|
||||
async getConfig(): Promise<Config> {
|
||||
async get(): Promise<Config> {
|
||||
await this.config.read();
|
||||
return this.config.data;
|
||||
}
|
||||
@@ -42,4 +41,4 @@ class ConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
export const configService = new ConfigService();
|
||||
export const appStateService = new AppStateService(appStatePath);
|
||||
@@ -0,0 +1,235 @@
|
||||
import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types';
|
||||
|
||||
import { copyFile, rename, stat, writeFile } from 'fs/promises';
|
||||
import { existsSync } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { notifyChanges, setRundown } from '../rundown-service/RundownService.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
|
||||
import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
|
||||
import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js';
|
||||
import { appStateService } from '../app-state-service/AppStateService.js';
|
||||
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { deleteFile } from '../../utils/parserUtils.js';
|
||||
|
||||
// init dependencies
|
||||
init();
|
||||
|
||||
/**
|
||||
* Ensure services has its dependencies initialized
|
||||
*/
|
||||
function init() {
|
||||
ensureDirectory(resolveProjectsDirectory);
|
||||
}
|
||||
|
||||
type Options = {
|
||||
onlyRundown?: 'true' | 'false';
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a file from the upload folder and applies its data
|
||||
*/
|
||||
export async function applyProjectFile(filePath: string, options?: Options) {
|
||||
const data = parseProjectFile(filePath);
|
||||
|
||||
// move file to project folder
|
||||
const filename = basename(filePath);
|
||||
const newFilePath = join(resolveProjectsDirectory, filename);
|
||||
await rename(filePath, newFilePath);
|
||||
|
||||
// apply data model
|
||||
await applyDataModel(data, options);
|
||||
|
||||
// persist the project selection
|
||||
await appStateService.updateDatabaseConfig(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
|
||||
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
|
||||
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
|
||||
*
|
||||
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
|
||||
* each representing a file in the 'uploads' folder with its metadata.
|
||||
* The metadata includes the filename, creation time (createdAt),
|
||||
* and last modification time (updatedAt) of each file.
|
||||
*
|
||||
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
|
||||
*/
|
||||
export async function getProjectFiles(): Promise<ProjectFile[]> {
|
||||
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
|
||||
const filteredFiles = filterProjectFiles(allFiles);
|
||||
|
||||
const projectFiles = [];
|
||||
for (const file of filteredFiles) {
|
||||
const filePath = join(resolveProjectsDirectory, file);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
projectFiles.push({
|
||||
filename: removeFileExtension(file),
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return projectFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers data related to the project list
|
||||
*/
|
||||
export async function getProjectList(): Promise<ProjectFileListResponse> {
|
||||
const files = await getProjectFiles();
|
||||
const appState = await appStateService.get();
|
||||
const lastLoadedProject = removeFileExtension(appState.lastLoadedProject);
|
||||
|
||||
return {
|
||||
files,
|
||||
lastLoadedProject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing project file
|
||||
*/
|
||||
export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
|
||||
const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile);
|
||||
|
||||
return copyFile(projectFilePath, duplicateProjectFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an existing project file
|
||||
*/
|
||||
export async function renameProjectFile(existingProjectFile: string, newName: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
|
||||
const newProjectFilePath = join(resolveProjectsDirectory, newName);
|
||||
|
||||
await rename(projectFilePath, newProjectFilePath);
|
||||
|
||||
// Update the last loaded project config if current loaded project is the one being renamed
|
||||
const { lastLoadedProject } = await appStateService.get();
|
||||
|
||||
if (lastLoadedProject === existingProjectFile) {
|
||||
await appStateService.updateDatabaseConfig(newName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new project file and applies its result
|
||||
*/
|
||||
export async function createProjectFile(filename: string, projectData: ProjectData) {
|
||||
const data = {
|
||||
...dbModel,
|
||||
project: {
|
||||
...dbModel.project,
|
||||
...projectData,
|
||||
},
|
||||
};
|
||||
|
||||
// create new file
|
||||
const newFile = join(resolveProjectsDirectory, filename);
|
||||
await writeFile(newFile, JSON.stringify(data));
|
||||
|
||||
// apply its data
|
||||
await applyDataModel(data);
|
||||
|
||||
appStateService.updateDatabaseConfig(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a project file
|
||||
*/
|
||||
export async function deleteProjectFile(filename: string) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, filename);
|
||||
await deleteFile(projectFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds business logic to gathering data for the info endpoint
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const { version, serverPort } = DataProvider.getSettings();
|
||||
const osc = DataProvider.getOsc();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
||||
const cssOverride = resolveStylesPath;
|
||||
|
||||
return {
|
||||
networkInterfaces: ni,
|
||||
version,
|
||||
serverPort,
|
||||
osc,
|
||||
cssOverride,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Business logic for resolving a string
|
||||
*/
|
||||
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
|
||||
if (value === null) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* applies a partial database model
|
||||
*/
|
||||
export async function applyDataModel(data: Partial<DatabaseModel>, options?: Options) {
|
||||
runtimeService.stop();
|
||||
|
||||
const newRundown = data.rundown || [];
|
||||
const { rundown, ...rest } = data;
|
||||
if (options?.onlyRundown === 'true') {
|
||||
setRundown(newRundown ?? []);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(rest);
|
||||
setRundown(rundown ?? []);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Validates the existence of project files.
|
||||
* @param {object} projectFiles
|
||||
* @param {string} projectFiles.projectFilename
|
||||
* @param {string} projectFiles.newFilename
|
||||
*
|
||||
* @returns {Promise<Array<string>>} Array of errors
|
||||
*
|
||||
*/
|
||||
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (projectFiles.filename) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename);
|
||||
|
||||
if (!existsSync(projectFilePath)) {
|
||||
errors.push('Project file does not exist');
|
||||
}
|
||||
}
|
||||
|
||||
if (projectFiles.newFilename) {
|
||||
const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename);
|
||||
|
||||
if (existsSync(projectFilePath)) {
|
||||
errors.push('New project file already exists');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { expect, vi } from 'vitest';
|
||||
import { getProjectFiles } from '../getFileListFromFolder.js';
|
||||
|
||||
import { getProjectFiles } from '../ProjectService.js';
|
||||
|
||||
vi.mock('fs/promises', () => {
|
||||
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { extname } from 'path';
|
||||
|
||||
/**
|
||||
* 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<string>): Array<string> {
|
||||
return files.filter((file) => {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return ext === '.json';
|
||||
});
|
||||
}
|
||||
|
||||
export function parseProjectFile(filePath: string): object {
|
||||
if (!filePath.endsWith('.json')) {
|
||||
throw new Error('Invalid file type');
|
||||
}
|
||||
|
||||
const rawdata = readFileSync(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 a ontime project file');
|
||||
}
|
||||
return uploadedJson;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { sheets, sheets_v4 } from '@googleapis/sheets';
|
||||
import { Credentials, OAuth2Client } from 'google-auth-library';
|
||||
import got from 'got';
|
||||
|
||||
import { resolveSheetsDirectory } from '../../setup.js';
|
||||
import { resolveSheetsDirectory } from '../../setup/index.js';
|
||||
import { ensureDirectory } from '../../utils/fileManagement.js';
|
||||
import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const config = {
|
||||
appState: 'app-state.json',
|
||||
database: {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
},
|
||||
demo: {
|
||||
directory: 'demo',
|
||||
filename: ['app.js', 'index.html', 'styles.css'],
|
||||
},
|
||||
projects: 'projects',
|
||||
sheets: {
|
||||
directory: 'sheets',
|
||||
},
|
||||
restoreFile: 'ontime.restore',
|
||||
styles: {
|
||||
directory: 'styles',
|
||||
filename: 'override.css',
|
||||
},
|
||||
uploads: 'uploads',
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import { fileURLToPath } from 'url';
|
||||
import path, { dirname, join } from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
import { config } from './config/config.js';
|
||||
import { ensureDirectory } from './utils/fileManagement.js';
|
||||
import { config } from './config.js';
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
|
||||
// =================================================
|
||||
// resolve public path
|
||||
@@ -44,10 +44,20 @@ export const isDocker = env === 'docker';
|
||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||
|
||||
// =================================================
|
||||
// Resolve directory paths
|
||||
|
||||
// resolve file URL in both CJS and ESM (build and dev)
|
||||
if (import.meta.url) {
|
||||
globalThis.__dirname = fileURLToPath(import.meta.url);
|
||||
}
|
||||
|
||||
// path to server src folder
|
||||
export const srcDirectory = path.join(dirname(__dirname), '../');
|
||||
|
||||
// resolve path to external
|
||||
const productionPath = '../../resources/extraResources/client';
|
||||
const devPath = '../../client/build/';
|
||||
const dockerPath = 'client/';
|
||||
const productionPath = path.join(srcDirectory, '../../resources/extraResources/client');
|
||||
const devPath = path.join(srcDirectory, '../../client/build/');
|
||||
const dockerPath = path.join(srcDirectory, 'client/');
|
||||
|
||||
export const resolvedPath = (): string => {
|
||||
if (isTest) {
|
||||
@@ -62,30 +72,23 @@ export const resolvedPath = (): string => {
|
||||
return devPath;
|
||||
};
|
||||
|
||||
// resolve file URL in both CJS and ESM (build and dev)
|
||||
if (import.meta.url) {
|
||||
globalThis.__dirname = fileURLToPath(import.meta.url);
|
||||
}
|
||||
|
||||
// path to server src folder
|
||||
export const currentDirectory = dirname(__dirname);
|
||||
|
||||
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
|
||||
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
|
||||
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(srcDirectory, 'external');
|
||||
// TODO: we only need one when they are all in the same folder
|
||||
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external');
|
||||
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : srcDirectory, 'external');
|
||||
|
||||
// project files
|
||||
export const lastLoadedProjectConfigPath = join(getAppDataPath(), 'config.json');
|
||||
export const uploadsFolderPath = join(getAppDataPath(), 'uploads');
|
||||
export const appStatePath = join(getAppDataPath(), config.appState);
|
||||
export const uploadsFolderPath = join(getAppDataPath(), config.uploads);
|
||||
|
||||
const getLastLoadedProject = () => {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject;
|
||||
const appState = JSON.parse(fs.readFileSync(appStatePath, 'utf8'));
|
||||
return appState.lastLoadedProject;
|
||||
} catch {
|
||||
if (!isTest) {
|
||||
ensureDirectory(getAppDataPath());
|
||||
fs.writeFileSync(lastLoadedProjectConfigPath, JSON.stringify({ lastLoadedProject: 'db.json' }));
|
||||
fs.writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -93,19 +96,19 @@ const getLastLoadedProject = () => {
|
||||
const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject();
|
||||
|
||||
// path to public db
|
||||
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : 'uploads');
|
||||
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
|
||||
export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename);
|
||||
|
||||
export const pathToStartDb = isTest
|
||||
? join(currentDirectory, '..', config.database.testdb, config.database.filename)
|
||||
: join(currentDirectory, '/preloaded-db/', config.database.filename);
|
||||
? join(srcDirectory, '..', config.database.testdb, config.database.filename)
|
||||
: join(srcDirectory, '/preloaded-db/', config.database.filename);
|
||||
|
||||
// TODO: move all static files to the external directory
|
||||
// path to public styles
|
||||
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||
|
||||
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
|
||||
export const pathToStartStyles = join(srcDirectory, '/external/styles/', config.styles.filename);
|
||||
|
||||
// path to public demo
|
||||
export const resolveDemoDirectory = join(
|
||||
@@ -118,7 +121,7 @@ export const resolveDemoPath = config.demo.filename.map((file) => {
|
||||
});
|
||||
|
||||
export const pathToStartDemo = config.demo.filename.map((file) => {
|
||||
return join(currentDirectory, '/external/demo/', file);
|
||||
return join(srcDirectory, '/external/demo/', file);
|
||||
});
|
||||
|
||||
// path to restore file
|
||||
@@ -129,3 +132,6 @@ export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.direc
|
||||
|
||||
// path to crash reports
|
||||
export const resolveCrashReportDirectory = getAppDataPath();
|
||||
|
||||
// path to projects
|
||||
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
|
||||
@@ -1,26 +1,34 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
import { Low } from 'lowdb';
|
||||
import { JSONFile } from 'lowdb/node';
|
||||
import { copyFileSync, existsSync } from 'fs';
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
import { join } from 'path';
|
||||
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
import { validateFile } from '../utils/parserUtils.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
|
||||
import { pathToStartDb, resolveDbDirectory, resolveDbPath } from './index.js';
|
||||
import { parseProjectFile } from '../services/project-service/projectFileUtils.js';
|
||||
import { parseJson } from '../utils/parser.js';
|
||||
import { pathToStartDb, resolveDbDirectory, resolveDbPath } from '../setup.js';
|
||||
|
||||
/**
|
||||
* @description ensures directories exist and populates database
|
||||
* @return {string} - path to db file
|
||||
*/
|
||||
const populateDb = () => {
|
||||
const dbInDisk = resolveDbPath;
|
||||
const populateDb = (): string => {
|
||||
// if everything goes well, the DB in disk is the one loaded
|
||||
let dbInDisk = resolveDbPath;
|
||||
ensureDirectory(resolveDbDirectory);
|
||||
|
||||
// if dbInDisk doesn't exist we want to use startup db
|
||||
if (!existsSync(dbInDisk)) {
|
||||
try {
|
||||
copyFileSync(pathToStartDb, dbInDisk);
|
||||
const dbDirectory = resolveDbDirectory;
|
||||
const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop());
|
||||
|
||||
copyFileSync(pathToStartDb, newFileDirectory);
|
||||
dbInDisk = newFileDirectory;
|
||||
} catch (_) {
|
||||
/* we do not handle this */
|
||||
}
|
||||
@@ -31,14 +39,14 @@ const populateDb = () => {
|
||||
|
||||
/**
|
||||
* @description parses a json file to the adapter
|
||||
* @param fileToRead
|
||||
* @param adapterToUse
|
||||
* @return {Promise<number|*>}
|
||||
* It will create an empty file from the model if the parsing fails
|
||||
*/
|
||||
const parseDb = async (fileToRead: string, adapterToUse: Low<DatabaseModel>) => {
|
||||
if (validateFile(fileToRead)) {
|
||||
const parseDatabase = async (fileToRead: string, adapterToUse: Low<DatabaseModel>) => {
|
||||
try {
|
||||
// this will throw if file is not valid
|
||||
parseProjectFile(fileToRead);
|
||||
await adapterToUse.read();
|
||||
} else {
|
||||
} catch (error) {
|
||||
adapterToUse.data = dbModel;
|
||||
}
|
||||
|
||||
@@ -55,12 +63,7 @@ async function loadDb() {
|
||||
const adapter = new JSONFile<DatabaseModel>(dbInDisk);
|
||||
const db = new Low(adapter, dbModel);
|
||||
|
||||
const data = await parseDb(dbInDisk, db);
|
||||
if (data === null) {
|
||||
console.error('ERROR: Invalid JSON format');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await parseDatabase(dbInDisk, db);
|
||||
db.data = data;
|
||||
await db.write();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from '../setup.js';
|
||||
import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from './index.js';
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
import { copyFileSync, existsSync } from 'fs';
|
||||
import { pathToStartStyles, resolveStylesDirectory, resolveStylesPath } from '../setup.js';
|
||||
import { pathToStartStyles, resolveStylesDirectory, resolveStylesPath } from './index.js';
|
||||
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||
|
||||
/**
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ensureJsonExtension } from '../ensureJsonExtension.js';
|
||||
import { ensureJsonExtension } from '../fileManagement.js';
|
||||
|
||||
describe('ensureJsonExtension', () => {
|
||||
it('should add .json to a filename without an extension', () => {
|
||||
@@ -1,5 +0,0 @@
|
||||
export const ensureJsonExtension = (filename: string) => {
|
||||
if (!filename) return filename;
|
||||
|
||||
return filename.includes('.json') ? filename : `${filename}.json`;
|
||||
};
|
||||
@@ -1,10 +1,12 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { readdir } from 'fs/promises';
|
||||
import { parse } from 'path';
|
||||
|
||||
/**
|
||||
* @description Creates a directory if it doesn't exist
|
||||
* @param {string} directory - directory that should exist or will be created
|
||||
*/
|
||||
export function ensureDirectory(directory: string) {
|
||||
export function ensureDirectory(directory: string): void {
|
||||
if (!existsSync(directory)) {
|
||||
try {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
@@ -13,3 +15,27 @@ export function ensureDirectory(directory: string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that a filename ends with .json extension
|
||||
*/
|
||||
export function ensureJsonExtension(filename: string): string {
|
||||
if (!filename) return filename;
|
||||
|
||||
return filename.includes('.json') ? filename : `${filename}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all files in a directory
|
||||
*/
|
||||
export async function getFilesFromFolder(folderPath: string): Promise<string[]> {
|
||||
return await readdir(folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Takes a filename and removes the extension
|
||||
* @param {string} filename - filename with extension
|
||||
*/
|
||||
export const removeFileExtension = (filename: string): string => {
|
||||
return parse(filename).name;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from 'path';
|
||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||
import { get } from '../services/rundown-service/rundownCache.js';
|
||||
import { getState } from '../stores/runtimeState.js';
|
||||
import { resolveCrashReportDirectory } from '../setup.js';
|
||||
import { resolveCrashReportDirectory } from '../setup/index.js';
|
||||
|
||||
/**
|
||||
* Writes a file to the crash report location
|
||||
|
||||
@@ -4,12 +4,8 @@ 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.
|
||||
*
|
||||
* @param {string} directory - The directory to check for file existence.
|
||||
* @param {string} filename - The original filename.
|
||||
* @return {Promise<string>} A unique filename.
|
||||
*/
|
||||
export const generateUniqueFileName = (directory: string, filename: string) => {
|
||||
export const generateUniqueFileName = (directory: string, filename: string): string => {
|
||||
const baseName = path.basename(filename, path.extname(filename));
|
||||
const extension = path.extname(filename);
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ProjectFile } from 'ontime-types';
|
||||
|
||||
import { getAppDataPath } from '../setup.js';
|
||||
|
||||
import { extname, join } from 'path';
|
||||
import { readdir, stat } from 'fs/promises';
|
||||
import { removeFileExtension } from './removeFileExtension.js';
|
||||
|
||||
const getFilesFromFolder = async (folderPath: string) => {
|
||||
return await readdir(folderPath);
|
||||
};
|
||||
|
||||
const filterProjectFiles = (files: Array<string>): Array<string> => {
|
||||
return files.filter((file) => {
|
||||
const ext = extname(file).toLowerCase();
|
||||
return ext === '.json';
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
|
||||
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
|
||||
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
|
||||
*
|
||||
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
|
||||
* each representing a file in the 'uploads' folder with its metadata.
|
||||
* The metadata includes the filename, creation time (createdAt),
|
||||
* and last modification time (updatedAt) of each file.
|
||||
*
|
||||
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
|
||||
*/
|
||||
export const getProjectFiles = async (): Promise<ProjectFile[]> => {
|
||||
const uploadsFolderPath = join(getAppDataPath(), 'uploads');
|
||||
|
||||
try {
|
||||
const allFiles = await getFilesFromFolder(uploadsFolderPath);
|
||||
const filteredFiles = filterProjectFiles(allFiles);
|
||||
|
||||
const projectFiles = [];
|
||||
for (const file of filteredFiles) {
|
||||
const filePath = join(uploadsFolderPath, file);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
projectFiles.push({
|
||||
filename: removeFileExtension(file),
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
updatedAt: stats.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return projectFiles;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { networkInterfaces } from 'os';
|
||||
|
||||
/**
|
||||
* @description Gets information on IPV4 non-internal interfaces
|
||||
* @returns {array} - Array of objects {name: ip}
|
||||
*/
|
||||
export function getNetworkInterfaces(): { name: string; address: string }[] {
|
||||
const nets = networkInterfaces();
|
||||
const results: { name: string; address: string }[] = [];
|
||||
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name]) {
|
||||
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
results.push({
|
||||
name,
|
||||
address: net.address,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -22,9 +22,7 @@ import {
|
||||
EventCustomFields,
|
||||
} from 'ontime-types';
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
import path from 'path';
|
||||
|
||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
@@ -40,7 +38,6 @@ import {
|
||||
parseCustomFields,
|
||||
} from './parserFunctions.js';
|
||||
import { parseExcelDate } from './time.js';
|
||||
import { configService } from '../services/ConfigService.js';
|
||||
import { coerceBoolean } from './coerceType.js';
|
||||
|
||||
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
@@ -368,53 +365,38 @@ type ResponseOK = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Middleware function that checks file type and calls relevant parser
|
||||
* @param {string} file - reference to file
|
||||
* @param options - import options
|
||||
* @return {object} - parse result message
|
||||
* Validates and calls parse on an excel file
|
||||
*/
|
||||
export const fileHandler = async (file: string, options: ImportOptions): Promise<Partial<ResponseOK>> => {
|
||||
export function handleMaybeExcel(file: string, options: ImportOptions) {
|
||||
const res: Partial<ResponseOK> = {};
|
||||
|
||||
const fileName = path.basename(file);
|
||||
|
||||
// check which file type are we dealing with
|
||||
if (file.endsWith('.xlsx')) {
|
||||
// we need to check that the options are applicable
|
||||
if (!isImportMap(options)) {
|
||||
throw new Error('Got incorrect options for spreadsheet import');
|
||||
}
|
||||
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase());
|
||||
|
||||
if (!excelData?.data) {
|
||||
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||
}
|
||||
|
||||
const dataFromExcel = parseExcel(excelData.data, options);
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
if (res.data.rundown.length < 1) {
|
||||
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
|
||||
}
|
||||
res.data.customFields = parseCustomFields(dataFromExcel);
|
||||
|
||||
deleteFile(file);
|
||||
|
||||
return res;
|
||||
if (!file.endsWith('.xlsx')) {
|
||||
throw new Error('unexpected extension for spreadsheet');
|
||||
}
|
||||
|
||||
if (file.endsWith('.json')) {
|
||||
const rawdata = fs.readFileSync(file).toString();
|
||||
let uploadedJson = null;
|
||||
|
||||
uploadedJson = JSON.parse(rawdata);
|
||||
res.data = await parseJson(uploadedJson);
|
||||
|
||||
await configService.updateDatabaseConfig(fileName);
|
||||
return res;
|
||||
// we need to check that the options are applicable
|
||||
if (!isImportMap(options)) {
|
||||
throw new Error('Got incorrect options for spreadsheet import');
|
||||
}
|
||||
};
|
||||
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase());
|
||||
|
||||
if (!excelData?.data) {
|
||||
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||
}
|
||||
|
||||
const dataFromExcel = parseExcel(excelData.data, options);
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
if (res.data.rundown.length < 1) {
|
||||
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
|
||||
}
|
||||
res.data.customFields = parseCustomFields(dataFromExcel);
|
||||
|
||||
deleteFile(file);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unlink, readFileSync } from 'fs';
|
||||
import { unlink } from 'fs';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
@@ -25,20 +25,6 @@ export const deleteFile = async (file) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Delete file from system
|
||||
* @param {string} file - reference to file
|
||||
* @returns {boolean} - whether file is valid JSON
|
||||
*/
|
||||
export const validateFile = (file) => {
|
||||
try {
|
||||
JSON.parse(readFileSync(file, 'utf-8'));
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Verifies if object is empty
|
||||
* @param {object} obj
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
import { isEmptyObject } from './parserUtils.js';
|
||||
|
||||
/**
|
||||
@@ -5,18 +7,17 @@ import { isEmptyObject } from './parserUtils.js';
|
||||
* @param obj
|
||||
* @param res
|
||||
*/
|
||||
export const failEmptyObjects = (obj, res) => {
|
||||
let failed = false;
|
||||
export const failEmptyObjects = (obj: object, res: Response): boolean => {
|
||||
try {
|
||||
if (isEmptyObject(obj)) {
|
||||
res.status(400).send('No object found in request');
|
||||
failed = true;
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
failed = true;
|
||||
return true;
|
||||
}
|
||||
return failed;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,16 +25,15 @@ export const failEmptyObjects = (obj, res) => {
|
||||
* @param obj
|
||||
* @param res
|
||||
*/
|
||||
export const failIsNotArray = (obj, res) => {
|
||||
let failed = false;
|
||||
export const failIsNotArray = (obj: object, res: Response): boolean => {
|
||||
try {
|
||||
if (!Array.isArray(obj)) {
|
||||
res.status(400).send('No array found in request');
|
||||
failed = true;
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
failed = true;
|
||||
return true;
|
||||
}
|
||||
return failed;
|
||||
return false;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { ensureJsonExtension } from './ensureJsonExtension.js';
|
||||
|
||||
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
|
||||
const { filename, newFilename } = req.body;
|
||||
const { filename: projectName } = req.params;
|
||||
|
||||
req.body.filename = ensureJsonExtension(filename);
|
||||
req.body.newFilename = ensureJsonExtension(newFilename);
|
||||
req.params.filename = ensureJsonExtension(projectName);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import fs from 'fs';
|
||||
|
||||
import { EXCEL_MIME, JSON_MIME } from './parser.js';
|
||||
import { ensureDirectory } from './fileManagement.js';
|
||||
import { getAppDataPath } from '../setup.js';
|
||||
import { getAppDataPath, uploadsFolderPath } from '../setup/index.js';
|
||||
|
||||
function generateNewFileName(filePath: string, callback: (newName: string) => void) {
|
||||
const baseName = path.basename(filePath, path.extname(filePath));
|
||||
@@ -37,20 +37,19 @@ const storage = multer.diskStorage({
|
||||
throw new Error('Could not resolve public folder for platform');
|
||||
}
|
||||
|
||||
const uploadsPath = path.join(appDataPath, 'uploads');
|
||||
ensureDirectory(uploadsPath);
|
||||
ensureDirectory(uploadsFolderPath);
|
||||
|
||||
const filePath = path.join(uploadsPath, file.originalname);
|
||||
const filePath = path.join(uploadsFolderPath, file.originalname);
|
||||
|
||||
// Check if file already exists
|
||||
fs.access(filePath, fs.constants.F_OK, (err) => {
|
||||
if (err) {
|
||||
// File does not exist, can safely proceed to this destination
|
||||
cb(null, uploadsPath);
|
||||
cb(null, uploadsFolderPath);
|
||||
} else {
|
||||
generateNewFileName(filePath, (newName) => {
|
||||
file.originalname = newName;
|
||||
cb(null, uploadsPath);
|
||||
cb(null, uploadsFolderPath);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user