diff --git a/apps/client/src/common/api/apiUtils.ts b/apps/client/src/common/api/apiUtils.ts index 420257057..8a1937e52 100644 --- a/apps/client/src/common/api/apiUtils.ts +++ b/apps/client/src/common/api/apiUtils.ts @@ -2,6 +2,7 @@ import axios, { AxiosError } from 'axios'; import { LogLevel } from 'ontime-types'; import { generateId, millisToString } from 'ontime-utils'; +import { ontimeQueryClient } from '../queryClient'; import { addLog } from '../stores/logger'; import { nowInMillis } from '../utils/time'; @@ -34,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) { text: message, }); } + +export async function invalidateAllCaches() { + await ontimeQueryClient.invalidateQueries([ + 'project', + 'aliases', + 'userFields', + 'rundown', + 'appinfo', + 'oscSettings', + 'appSettings', + 'viewSettings', + ]); +} diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 6e9d6b4c6..e3cadd9fb 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -1,5 +1,16 @@ -import axios from 'axios'; -import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types'; +import axios, { AxiosResponse } from 'axios'; +import { + Alias, + DatabaseModel, + OntimeRundown, + OSCSettings, + OscSubscription, + ProjectData, + Settings, + UserFields, + ViewSettings, +} from 'ontime-types'; +import { ExcelImportMap } from 'ontime-utils'; import { apiRepoLatest } from '../../externals'; import { InfoType } from '../models/Info'; @@ -137,17 +148,26 @@ export const downloadRundown = async () => { }); }; +// TODO: should this be extracted to shared code? +export type ProjectFileImportOptions = { + onlyRundown: boolean; +}; + /** * @description HTTP request to upload events db * @return {Promise} */ -type UploadDataOptions = { - onlyRundown?: boolean; -}; -export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => { +export const uploadProjectFile = async ( + file: File, + setProgress: (value: number) => void, + options?: Partial, +) => { const formData = new FormData(); formData.append('userFile', file); - const onlyRundown = options?.onlyRundown || 'false'; + + const onlyRundown = Boolean(options?.onlyRundown); + console.log('debug here', onlyRundown, options); + await axios .post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, { headers: { @@ -161,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi .then((response) => response.data.id); }; +/** + * @description Make patch changes to the objects in the db + * @return {Promise} + */ +export async function patchData(patchDb: Partial) { + const response = await axios.patch(`${ontimeURL}/db`, patchDb); + return response; +} + +type PostPreviewExcelResponse = { + rundown: OntimeRundown; + project: ProjectData; + userFields: UserFields; +}; + +/** + * @description Make patch changes to the objects in the db + * @return {Promise} - returns parsed rundown and userfields + */ +export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) { + const formData = new FormData(); + formData.append('userFile', file); + formData.append('options', JSON.stringify(options)); + + const response: AxiosResponse = await axios.post( + `${ontimeURL}/preview-spreadsheet`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + onUploadProgress: (progressEvent) => { + const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0; + setProgress(complete); + }, + }, + ); + + return response; +} + export type HasUpdate = { url: string; version: string; diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 4cc0f2513..18c911f44 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -1,4 +1,4 @@ -import { Alias, LogOrigin, ProjectData } from 'ontime-types'; +import { Alias, DatabaseModel, LogOrigin, ProjectData } from 'ontime-types'; import { RequestHandler } from 'express'; import fs from 'fs'; @@ -7,13 +7,15 @@ import { networkInterfaces } from 'os'; import { fileHandler } from '../utils/parser.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; -import { mergeObject } from '../utils/parserUtils.js'; import { PlaybackService } from '../services/PlaybackService.js'; import { eventStore } from '../stores/EventStore.js'; import { isDocker, resolveDbPath } from '../setup.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { logger } from '../classes/Logger.js'; -import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js'; +import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js'; +import { deepmerge } from 'ontime-utils'; +import { runtimeCacheStore } from '../stores/cachingStore.js'; +import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js'; // Create controller for GET request to '/ontime/poll' // Returns data for current state @@ -43,44 +45,43 @@ export const dbDownload = async (req, res) => { }); }; +// TODO: docs +// TODO: cleanup usage /** - * handles file upload + * Parses a file and returns the result objects + * @param file + * @param _req + * @param _res + * @param options + */ +async function parseFile(file, _req, _res, options) { + if (!fs.existsSync(file)) { + throw new Error('Upload failed'); + } + const result = await fileHandler(file, options); + return result.data; +} + +/** + * parse an uploaded file and apply its parsed objects * @param file * @param req * @param res * @param [options] * @returns {Promise} */ -const uploadAndParse = async (file, req, res, options) => { - if (!fs.existsSync(file)) { - res.status(500).send({ message: 'Upload failed' }); - return; - } +const parseAndApply = async (file, _req, res, options) => { + const result = await parseFile(file, _req, res, options); - try { - const result = await fileHandler(file); + PlaybackService.stop(); - if ('error' in result && result.error) { - res.status(400).send({ message: result.message }); - } else if ('data' in result && result.message === 'success') { - PlaybackService.stop(); - // explicitly write objects - if (typeof result !== 'undefined') { - const newRundown = result.data.rundown || []; - if (options?.onlyRundown === 'true') { - await DataProvider.setRundown(newRundown); - } else { - await DataProvider.mergeIntoData(result.data); - } - } - forceReset(); - res.sendStatus(200); - } else { - res.status(400).send({ message: 'Failed parsing, no data' }); - } - } catch (error) { - res.status(400).send({ message: `Failed parsing ${error}` }); + const newRundown = result.rundown || []; + if (options?.onlyRundown === 'true') { + await DataProvider.setRundown(newRundown); + } else { + await DataProvider.mergeIntoData(result); } + notifyChanges({ timer: true, external: true, reset: true }); }; /** @@ -169,7 +170,7 @@ export const postUserFields = async (req, res) => { } try { const persistedData = DataProvider.getUserFields(); - const newData = mergeObject(persistedData, req.body); + const newData = deepmerge(persistedData, req.body); await DataProvider.setUserFields(newData); res.status(200).send(newData); } catch (error) { @@ -319,8 +320,38 @@ export const postOSC = async (req, res) => { } }; -// Create controller for POST request to '/ontime/db' -// Returns - +export async function patchPartialProjectFile(req, res) { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const patchDb: Partial = { + project: req.body?.project, + settings: req.body?.settings, + viewSettings: req.body?.viewSettings, + osc: req.body?.osc, + aliases: req.body?.aliases, + userFields: req.body?.userFields, + rundown: req.body?.rundown, + }; + + await DataProvider.mergeIntoData(patchDb); + if (patchDb.rundown !== undefined) { + // it is likely cheaper to invalidate cache than to calculate diff + PlaybackService.stop(); + runtimeCacheStore.invalidate(delayedRundownCacheKey); + notifyChanges({ external: true, reset: true }); + } + res.status(200).send(); + } catch (error) { + res.status(400).send(error); + } +} + +/** + * uploads and parses a given file + */ export const dbUpload = async (req, res) => { if (!req.file) { res.status(400).send({ message: 'File not found' }); @@ -328,10 +359,39 @@ export const dbUpload = async (req, res) => { } const options = req.query; const file = req.file.path; - await uploadAndParse(file, req, res, options); + try { + await parseAndApply(file, req, res, options); + res.status(200).send(); + } catch (error) { + res.status(400).send({ message: `Failed parsing ${error}` }); + } }; -// Create controller for POST request to '/ontime/new' +/** + * uploads and parses an excel file + * @returns parsed result + */ +export async function previewExcel(req, res) { + if (!req.file) { + res.status(400).send({ message: 'File not found' }); + return; + } + + try { + const options = JSON.parse(req.body.options); + const file = req.file.path; + const data = await parseFile(file, req, res, options); + res.status(200).send(data); + } catch (error) { + res.status(500).send({ message: error.toString() }); + } +} + +/** + * Meant to create a new project file, it will clear only fields which are specific to a project + * @param req + * @param res + */ export const postNew: RequestHandler = async (req, res) => { try { const newProjectData: ProjectData = { diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 5f67aa4e3..8be1c8bf5 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -118,3 +118,18 @@ export const validateOscSubscription = [ next(); }, ]; + +export const validatePatchProjectFile = [ + body('rundown').isArray().optional({ nullable: false }), + body('project').isObject().optional({ nullable: false }), + body('settings').isObject().optional({ nullable: false }), + body('viewSettings').isObject().optional({ nullable: false }), + body('aliases').isArray().optional({ nullable: false }), + body('userFields').isObject().optional({ nullable: false }), + body('osc').isObject().optional({ nullable: false }), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 8ff789817..67f4fb033 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -9,6 +9,7 @@ import { getSettings, getUserFields, getViewSettings, + patchPartialProjectFile, poll, postAliases, postNew, @@ -17,12 +18,14 @@ import { postSettings, postUserFields, postViewSettings, + previewExcel, } from '../controllers/ontimeController.js'; import { validateAliases, validateOSC, validateOscSubscription, + validatePatchProjectFile, validateSettings, validateUserFields, viewValidator, @@ -40,6 +43,12 @@ router.get('/db', dbDownload); // create route between controller and '/ontime/db' endpoint router.post('/db', uploadFile, dbUpload); +// create route between controller and '/ontime/excel' endpoint +router.patch('/db', validatePatchProjectFile, patchPartialProjectFile); + +// create route between controller and '/ontime/preview-spreadsheet' endpoint +router.post('/preview-spreadsheet', uploadFile, previewExcel); + // create route between controller and '/ontime/settings' endpoint router.get('/settings', getSettings);