mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-05 15:33:59 +00:00
433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
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<ExcelImportMap>) => {
|
|
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
|
const projectData: Partial<ProjectData> = {
|
|
title: '',
|
|
description: '',
|
|
publicUrl: '',
|
|
publicInfo: '',
|
|
backstageUrl: '',
|
|
backstageInfo: '',
|
|
};
|
|
const customUserFields: Partial<UserFields> = {
|
|
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<OntimeEvent> = {};
|
|
|
|
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<DatabaseModel | null> => {
|
|
if (!jsonData || typeof jsonData !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
// object containing the parsed data
|
|
const returnData: Partial<DatabaseModel> = {};
|
|
|
|
// 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<OntimeEvent>, 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<DatabaseModel>;
|
|
};
|
|
|
|
/**
|
|
* @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<Partial<ResponseOK>> => {
|
|
const res: Partial<ResponseOK> = {};
|
|
|
|
// 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;
|
|
}
|
|
};
|