import { generateId, calculateDuration, isExcelImportMap, type ExcelImportMap, defaultExcelImportMap, validateEndAction, validateTimerType, type ExcelImportOptions, } from 'ontime-utils'; import { DatabaseModel, OntimeEvent, OntimeRundown, SupportedEvent, ProjectData, UserFields } from 'ontime-types'; import fs from 'fs'; import xlsx from 'node-xlsx'; import { event as eventDef } from '../models/eventsDefinition.js'; import { dbModel } from '../models/dataModel.js'; import { deleteFile, makeString } from './parserUtils.js'; import { parseAliases, parseProject, parseOsc, parseRundown, parseSettings, parseUserFields, parseViewSettings, } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; /** * @description Excel array parser * @param {array} excelData - array with excel sheet * @param {ExcelImportOptions} options - an object that contains the import map * @returns {object} - parsed object */ export const parseExcel = (excelData: unknown[][], options?: Partial) => { const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options }; const projectData: Partial = { title: '', description: '', publicUrl: '', publicInfo: '', backstageUrl: '', backstageInfo: '', }; const customUserFields: Partial = { user0: importMap.user0, user1: importMap.user1, user2: importMap.user2, user3: importMap.user3, user4: importMap.user4, user5: importMap.user5, user6: importMap.user6, user7: importMap.user7, user8: importMap.user8, user9: importMap.user9, }; const rundown: OntimeRundown = []; // title stuff: strings let titleIndex: number | null = null; let cueIndex: number | null = null; let presenterIndex: number | null = null; let subtitleIndex: number | null = null; let notesIndex: number | null = null; let colourIndex: number | null = null; // options: booleans let isPublicIndex: number | null = null; let skipIndex: number | null = null; // times: numbers // TODO: handle duration let timeStartIndex: number | null = null; let timeEndIndex: number | null = null; // options: enum properties let endActionIndex: number | null = null; let timerTypeIndex: number | null = null; // user fields: strings let user0Index: number | null = null; let user1Index: number | null = null; let user2Index: number | null = null; let user3Index: number | null = null; let user4Index: number | null = null; let user5Index: number | null = null; let user6Index: number | null = null; let user7Index: number | null = null; let user8Index: number | null = null; let user9Index: number | null = null; excelData .filter((e) => e.length > 0) .forEach((row) => { // these fields contain the data to its right let eventTitleNext = false; let projectTitleNext = false; let publicUrlNext = false; let publicInfoNext = false; let backstageUrlNext = false; let backstageInfoNext = false; const event: Partial = {}; row.forEach((column, j) => { // 1. we check if we have set a flag for a known field if (eventTitleNext) { projectData.title = makeString(column, ''); eventTitleNext = false; } else if (projectTitleNext) { projectData.description = makeString(column, ''); projectTitleNext = false; } else if (publicUrlNext) { projectData.publicUrl = makeString(column, ''); publicUrlNext = false; } else if (publicInfoNext) { projectData.publicInfo = makeString(column, ''); publicInfoNext = false; } else if (backstageUrlNext) { projectData.backstageUrl = makeString(column, ''); backstageUrlNext = false; } else if (backstageInfoNext) { projectData.backstageInfo = makeString(column, ''); backstageInfoNext = false; } else if (j === timeStartIndex) { event.timeStart = parseExcelDate(column); } else if (j === timeEndIndex) { event.timeEnd = parseExcelDate(column); } else if (j === titleIndex) { event.title = makeString(column, ''); } else if (j === cueIndex) { event.cue = makeString(column, ''); } else if (j === presenterIndex) { event.presenter = makeString(column, ''); } else if (j === subtitleIndex) { event.subtitle = makeString(column, ''); } else if (j === isPublicIndex) { event.isPublic = Boolean(column); } else if (j === skipIndex) { event.skip = Boolean(column); } else if (j === notesIndex) { event.note = makeString(column, ''); } else if (j === endActionIndex) { event.endAction = validateEndAction(column); } else if (j === timerTypeIndex) { event.timerType = validateTimerType(column); } else if (j === colourIndex) { event.colour = makeString(column, ''); } else if (j === user0Index) { event.user0 = makeString(column, ''); } else if (j === user1Index) { event.user1 = makeString(column, ''); } else if (j === user2Index) { event.user2 = makeString(column, ''); } else if (j === user3Index) { event.user3 = makeString(column, ''); } else if (j === user4Index) { event.user4 = makeString(column, ''); } else if (j === user5Index) { event.user5 = makeString(column, ''); } else if (j === user6Index) { event.user6 = makeString(column, ''); } else if (j === user7Index) { event.user7 = makeString(column, ''); } else if (j === user8Index) { event.user8 = makeString(column, ''); } else if (j === user9Index) { event.user9 = makeString(column, ''); } else { // 2. if there is no flag, lets see if we know the field type if (typeof column === 'string') { const col = column.toLowerCase(); // look for keywords // need to make sure it is a string first switch (col) { case importMap.projectName: eventTitleNext = true; break; case importMap.projectDescription: projectTitleNext = true; break; case importMap.publicUrl: publicUrlNext = true; break; case importMap.publicInfo: publicInfoNext = true; break; case importMap.backstageUrl: backstageUrlNext = true; break; case importMap.backstageInfo: backstageInfoNext = true; break; case importMap.timeStart: timeStartIndex = j; break; case importMap.timeEnd: timeEndIndex = j; break; case importMap.cue: cueIndex = j; break; case importMap.title: titleIndex = j; break; case importMap.presenter: presenterIndex = j; break; case importMap.subtitle: subtitleIndex = j; break; case importMap.isPublic: isPublicIndex = j; break; case importMap.skip: skipIndex = j; break; case importMap.note: notesIndex = j; break; case importMap.colour: colourIndex = j; break; case importMap.endAction: endActionIndex = j; break; case importMap.timerType: timerTypeIndex = j; break; case importMap.user0: user0Index = j; break; case importMap.user1: user1Index = j; break; case importMap.user2: user2Index = j; break; case importMap.user3: user3Index = j; break; case importMap.user4: user4Index = j; break; case importMap.user5: user5Index = j; break; case importMap.user6: user6Index = j; break; case importMap.user7: user7Index = j; break; case importMap.user8: user8Index = j; break; case importMap.user9: user9Index = j; break; default: // we don't know how to handle this column // just ignore it } } } }); if (Object.keys(event).length > 0) { // if any data was found, push to array // take care of it in the next step rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); } }); return { rundown, project: projectData, settings: { app: 'ontime', version: 2, }, userFields: customUserFields, }; }; /** * @description JSON parser function for ontime project file * @param {object} jsonData - project file to be parsed * @returns {object} - parsed object */ export const parseJson = async (jsonData): Promise => { if (!jsonData || typeof jsonData !== 'object') { return null; } // object containing the parsed data const returnData: Partial = {}; // parse Events returnData.rundown = parseRundown(jsonData); // parse Event returnData.project = parseProject(jsonData) ?? dbModel.project; // Settings handled partially returnData.settings = parseSettings(jsonData) ?? dbModel.settings; // View settings handled partially returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings; // Import Aliases if any returnData.aliases = parseAliases(jsonData); // Import user fields if any returnData.userFields = parseUserFields(jsonData); // Import OSC settings if any returnData.osc = parseOsc(jsonData) ?? dbModel.osc; // Import HTTP settings if any // returnData.http = parseHttp(jsonData, enforce); return returnData as DatabaseModel; }; /** * @description Enforces formatting for events * @param {object} eventArgs - attributes of event * @param cueFallback * @returns {object|null} - formatted object or null in case is invalid */ export const validateEvent = (eventArgs: Partial, cueFallback: string) => { // ensure id is defined and unique const id = eventArgs.id || generateId(); let event = null; // return if object is empty if (Object.keys(eventArgs).length > 0) { // make sure all properties exits // dont load any extra properties than the ones known const e = eventArgs; const d = eventDef; const start = e.timeStart != null && typeof e.timeStart === 'number' ? e.timeStart : d.timeStart; const end = e.timeEnd != null && typeof e.timeEnd === 'number' ? e.timeEnd : d.timeEnd; event = { ...d, title: makeString(e.title, d.title), subtitle: makeString(e.subtitle, d.subtitle), presenter: makeString(e.presenter, d.presenter), timeStart: start, timeEnd: end, endAction: makeString(e.endAction, d.endAction), timerType: makeString(e.timerType, d.timerType), duration: calculateDuration(start, end), isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic, skip: typeof e.skip === 'boolean' ? e.skip : d.skip, note: makeString(e.note, d.note), user0: makeString(e.user0, d.user0), user1: makeString(e.user1, d.user1), user2: makeString(e.user2, d.user2), user3: makeString(e.user3, d.user3), user4: makeString(e.user4, d.user4), user5: makeString(e.user5, d.user5), user6: makeString(e.user6, d.user6), user7: makeString(e.user7, d.user7), user8: makeString(e.user8, d.user8), user9: makeString(e.user9, d.user9), colour: makeString(e.colour, d.colour), id, cue: makeString(e.cue, cueFallback), type: 'event', }; } return event; }; type ResponseOK = { data: Partial; }; /** * @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 */ export const fileHandler = async (file: string, options: ExcelImportOptions): Promise> => { const res: Partial = {}; // check which file type are we dealing with if (file.endsWith('.xlsx')) { // we need to check that the options are applicable if (!isExcelImportMap(options)) { throw new Error('Got incorrect options to excel import', JSON.parse(options)); } const excelData = xlsx .parse(file, { cellDates: true }) .find(({ name }) => name.toLowerCase() === options.worksheet); if (excelData?.data) { 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); res.data.project = parseProject(dataFromExcel); res.data.userFields = parseUserFields(dataFromExcel); return res; } else { throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`); } } if (file.endsWith('.json')) { // if json check version const rawdata = fs.readFileSync(file).toString(); let uploadedJson = null; uploadedJson = JSON.parse(rawdata); if (uploadedJson.settings.version !== 2) { throw new Error(`Project version unknown ${uploadedJson.settings.version}`); } res.data = await parseJson(uploadedJson); // delete file await deleteFile(file); return res; } };