Feat/excel (#45)

* add dependency

adding dependency to parse the excel file in the node side

* import file

ability to import file
- add extension
- safeguard file type and size

* user is able to upload json or excel

* chore: extract logic into self contained function

* associate model with version

* test id generation

* parse excel data

* prevent UI crash on bad data

* extract validation into self contained function

* chore: add more tests for bad data

* config: stop from opening browser on start

* fix comma in sample db

* handle corrupt files

* error boundary around main components

* jsdocs

* increase id length

* parse excel time string

* feat: excel parsing

* validate json db before import

* chore: test event validator

* feat: make function to clean convert strings

* chore: test parser

* chore: jsdocs

* fix: bug on upload

prevent bug where the component would prevent upload of same file twice
This commit is contained in:
Carlos Valente
2021-11-24 22:07:31 +01:00
committed by GitHub
parent bf8170a947
commit 8c740b7111
22 changed files with 1222 additions and 169 deletions
+369
View File
@@ -0,0 +1,369 @@
import fs from 'fs';
import xlsx from 'node-xlsx';
import {
event as eventDef,
delay as delayDef,
block as blockDef,
} from '../models/eventsDefinition.js';
import { dbModelv1 } from '../models/dataModel.js';
import { generateId } from './generate_id.js';
import { excelDateStringToMillis } from './time.js';
export const EXCEL_MIME =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json';
export const ALLOWED_TYPES = ['JSON', 'EXCEL'];
/**
* @description Middleware function that checks file type and calls relevant parser
* @argument {string} file - reference to file
* @return {object} - parse result message
*/
export const fileHandler = async (file) => {
let res = {};
// check which file type are we dealing with
if (file.endsWith('.xlsx')) {
try {
const excelData = xlsx
.parse(file, { cellDates: true })
.find(
({ name }) =>
name.toLowerCase() === 'ontime' ||
name.toLowerCase() === 'event schedule'
);
// we only look at worksheets called ontime or event schedule
if (excelData?.data) {
const dataFromExcel = await parseExcelv1(excelData.data);
res.data = await parseJsonv1(dataFromExcel);
res.message = 'success';
} else {
console.log('Error: No sheets found named ontime or event schedule');
res = {
error: true,
message: `No sheets found named ontime or event schedule`,
};
}
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
}
}
if (file.endsWith('.json')) {
// if json check version
const rawdata = fs.readFileSync(file);
let uploadedJson = null;
try {
uploadedJson = JSON.parse(rawdata);
} catch (error) {
return { error: true, message: 'Error parsing JSON file' };
}
if (uploadedJson.settings.version === 1) {
try {
res.data = await parseJsonv1(uploadedJson);
res.message = 'success';
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
}
} else {
res = { error: true, message: 'Error parsing file, version unknown' };
}
}
// delete file
deleteFile(file);
return res;
};
/**
* @description Excel array parser
* @argument {array} excelData - array with excel sheet
* @returns {object} - parsed object
*/
export const parseExcelv1 = async (excelData) => {
let eventData = {
title: '',
url: '',
};
let events = [];
let timeStartIndex = null;
let timeEndIndex = null;
let titleIndex = null;
let presenterIndex = null;
let subtitleIndex = null;
let isPublicIndex = null;
let notesIndex = null;
excelData
.filter((e) => e.length > 0)
.forEach((row) => {
let eventTitleNext = false;
let eventUrlNext = false;
let event = {};
row.forEach((column, j) => {
// check flags
if (eventTitleNext) {
eventData.title = column;
eventTitleNext = false;
} else if (eventUrlNext) {
eventData.url = column;
eventUrlNext = false;
} else if (j === timeStartIndex) {
event.timeStart = excelDateStringToMillis(column);
} else if (j === timeEndIndex) {
event.timeEnd = excelDateStringToMillis(column);
} else if (j === titleIndex) {
event.title = column;
} else if (j === presenterIndex) {
event.presenter = column;
} else if (j === subtitleIndex) {
event.subtitle = column;
} else if (j === isPublicIndex) {
// whether column is not empty
event.isPublic = column !== '';
} else if (j === notesIndex) {
event.note = column;
} else {
if (typeof column === 'string') {
// look for keywords
// need to make sure it is a string first
switch (column.toLowerCase()) {
case 'event name':
eventTitleNext = true;
break;
case 'event url':
eventUrlNext = true;
break;
case 'time start':
case 'start':
timeStartIndex = j;
break;
case 'time end':
case 'end':
case 'finish':
timeEndIndex = j;
break;
case 'event title':
case 'title':
titleIndex = j;
break;
case 'presenter name':
case 'speaker':
case 'presenter':
presenterIndex = j;
break;
case 'event subtitle':
case 'subtitle':
subtitleIndex = j;
break;
case 'is public? (x)':
case 'is public':
case 'public':
isPublicIndex = j;
break;
case 'notes':
notesIndex = j;
break;
default:
break;
}
}
}
});
if (Object.keys(event).length > 0) {
// if any data was found, push to array
// take care of it in the next step
events.push({ ...event, type: 'event' });
}
});
return {
events,
event: eventData,
settings: {
app: 'ontime',
version: 1,
},
};
};
/**
* @description JSON parser function for v1 of data system
* @argument {object} jsonData - json data JSON object to be parsed
* @returns {object} - parsed object
*/
export const parseJsonv1 = async (jsonData) => {
if (!jsonData || typeof jsonData !== 'object') {
console.log('ERROR: Invalid JSON format');
return -1;
}
let numEntries = 0;
let returnData = {};
if ('events' in jsonData) {
console.log('Found events definition, importing...');
let events = [];
let ids = [];
for (const e of jsonData.events) {
// doublecheck unique ids
if (ids.indexOf(e?.id) !== -1) {
console.log('ERROR: ID colision on import, skipping');
continue;
}
if (e.type === 'event') {
let event = validateEventv1(e);
if (event != null) {
events.push(event);
ids.push(event.id);
numEntries++;
}
} else if (e.type === 'delay') {
events.push({ ...delayDef, duration: e.duration });
numEntries++;
} else if (e.type === 'block') {
events.push({ ...blockDef });
numEntries++;
} else {
console.log('ERROR: undefined event type, skipping');
}
}
// write to db
returnData.events = events;
console.log(`Uploaded file with ${numEntries} entries`);
}
if ('event' in jsonData) {
console.log('Found event data, importing...');
const e = jsonData.event;
// filter known properties
const event = {
...dbModelv1.event,
title: e.title || dbModelv1.event.title,
url: e.url || dbModelv1.event.url,
publicInfo: e.publicInfo || dbModelv1.event.publicInfo,
backstageInfo: e.backstageInfo || dbModelv1.event.backstageInfo,
endMessage: e.endMessage || dbModelv1.event.endMessage,
};
// write to db
returnData.event = event;
}
// Settings handled partially
if ('settings' in jsonData) {
console.log('Found settings definition, importing...');
const s = jsonData.settings;
// skip if file definition is missing
if (s.app == null || s.version == null) {
console.log('ERROR: unknown app version, skipping');
} else {
let settings = {};
if (s.oscInPort) settings.oscInPort = s.oscInPort;
if (s.oscOutPort) settings.oscOutPort = s.oscOutPort;
if (s.oscOutIP) settings.oscOutIP = s.oscOutIP;
// write to db
returnData.settings = {
...dbModelv1.settings,
...settings,
};
}
}
return returnData;
};
/**
* @description Ensures variable is string, it skips object types
* @param {any} val - variable to convert
* @param {string} [fallback=''] - fallback value
* @returns {string} - value as string or fallback if not possibe
*/
export const makeString = (val, fallback = '') => {
if (typeof val === 'string') return val;
else if (val == null || val.constructor === Object) return fallback;
else return val.toString();
};
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @returns {object|null} - formatted object or null in case is invalid
*/
export const validateEventv1 = (eventArgs) => {
// 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;
event = {
...d,
title: makeString(e.title, d.title),
subtitle: makeString(e.subtitle, d.subtitle),
presenter: makeString(e.presenter, d.presenter),
note: makeString(e.note, d.note),
timeStart:
e.timeStart != null && typeof e.timeStart === 'number'
? e.timeStart
: d.timeStart,
timeEnd:
e.timeEnd != null && typeof e.timeEnd === 'number'
? e.timeEnd
: d.timeEnd,
isPublic:
e.isPublic != null && typeof e.isPublic === 'boolean'
? e.isPublic
: d.isPublic,
id,
type: 'event',
};
}
return event;
};
/**
* @description Delete file from system
* @argument {string} file - reference to file
*/
const deleteFile = async (file) => {
// delete a file
fs.unlink(file, (err) => {
if (err) {
console.log(err);
}
});
};
/**
* @description Delete file from system
* @argument {string} file - reference to file
*/
export const validateFile = (file) => {
try {
JSON.parse(fs.readFileSync(file));
return true;
} catch (err) {
return false;
}
};