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
+1 -1
View File
@@ -27,7 +27,7 @@
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"start": "set BROWSER=none&&react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
+19 -16
View File
@@ -4,6 +4,7 @@ import './App.css';
import { QueryClient, QueryClientProvider } from 'react-query';
import SocketProvider from 'app/context/socketContext';
import withSocket from 'features/viewers/ViewWrapper';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
const Editor = lazy(() => import('features/editors/Editor'));
const PresenterView = lazy(() =>
@@ -65,22 +66,24 @@ function App() {
<SocketProvider>
<QueryClientProvider client={queryClient}>
<div className='App'>
<Suspense fallback={null}>
<Switch>
<Route exact path='/' component={SSpeaker} />
<Route exact path='/sm' component={SStageManager} />
<Route exact path='/speaker' component={SSpeaker} />
<Route exact path='/stage' component={SSpeaker} />
<Route exact path='/speakersimple' component={SSpeakerSimple} />
<Route exact path='/editor' component={Editor} />
<Route exact path='/public' component={SPublic} />
<Route exact path='/pip' component={SPip} />
{/* Lower cannot have fallback */}
<Route exact path='/lower' component={SLowerThird} />
{/* Send to default if nothing found */}
<Route component={SSpeaker} />
</Switch>
</Suspense>
<ErrorBoundary>
<Suspense fallback={null}>
<Switch>
<Route exact path='/' component={SSpeaker} />
<Route exact path='/sm' component={SStageManager} />
<Route exact path='/speaker' component={SSpeaker} />
<Route exact path='/stage' component={SSpeaker} />
<Route exact path='/speakersimple' component={SSpeakerSimple} />
<Route exact path='/editor' component={Editor} />
<Route exact path='/public' component={SPublic} />
<Route exact path='/pip' component={SPip} />
{/* Lower cannot have fallback */}
<Route exact path='/lower' component={SLowerThird} />
{/* Send to default if nothing found */}
<Route component={SSpeaker} />
</Switch>
</Suspense>
</ErrorBoundary>
</div>
</QueryClientProvider>
</SocketProvider>
+1 -1
View File
@@ -47,7 +47,7 @@ export const downloadEvents = async () => {
export const uploadEvents = async (file) => {
const formData = new FormData();
formData.append('jsondb', file); // appending file
formData.append('userFile', file); // appending file
await axios
.post(ontimeURL + '/db', formData, {
headers: {
@@ -0,0 +1,35 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state so next render shows fallback UI.
return { errorMessage: error.toString() };
}
componentDidCatch(error, info) {
this.setState({
error: error,
errorInfo: info,
});
// TODO: Log the error to an error reporting service
this.logErrorToServices(error.toString(), info.componentStack);
}
// A fake logging service.
logErrorToServices = console.log;
render() {
if (this.state.errorMessage) {
// You can render any custom fallback UI
return <p>:/</p>;
}
return this.props.children;
}
}
export default ErrorBoundary;
+16 -5
View File
@@ -4,6 +4,7 @@ import { useDisclosure } from '@chakra-ui/hooks';
import styles from './Editor.module.css';
import MenuBar from 'features/menu/MenuBar';
import ModalManager from 'features/modals/ModalManager';
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
const EventListWrapper = lazy(() =>
import('features/editors/list/EventListWrapper')
@@ -26,34 +27,44 @@ export default function Editor() {
<div className={styles.mainContainer}>
<Box id='settings' className={styles.settings}>
<MenuBar onOpen={onOpen} />
<ErrorBoundary>
<MenuBar onOpen={onOpen} />
</ErrorBoundary>
</Box>
<Box className={styles.editor}>
<h1>Event List</h1>
<div className={styles.content}>
<EventListWrapper />
<ErrorBoundary>
<EventListWrapper />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.messages}>
<h1>Display Messages</h1>
<div className={styles.content}>
<MessageControl />
<ErrorBoundary>
<MessageControl />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.playback}>
<h1>Timer Control</h1>
<div className={styles.content}>
<PlaybackControl />
<ErrorBoundary>
<PlaybackControl />
</ErrorBoundary>
</div>
</Box>
<Box className={styles.info}>
<h1>Info</h1>
<div className={styles.content}>
<Info />
<ErrorBoundary>
<Info />
</ErrorBoundary>
</div>
</Box>
</div>
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from 'react-query';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
fetchAllEvents,
requestPatch,
@@ -26,6 +26,7 @@ export default function EventListWrapper() {
EVENTS_TABLE,
fetchAllEvents
);
const [events, setEvents] = useState(null);
const addEvent = useMutation(requestPost, {
// we optimistically update here
@@ -327,11 +328,17 @@ export default function EventListWrapper() {
[data]
);
// Front end should handle bad arguments
useEffect(() => {
if (data == null) return;
setEvents(data.filter((d) => Object.keys(d).length > 0));
}, [data]);
return (
<>
<EventListMenu eventsHandler={eventsHandler} />
{status === 'success' ? (
<EventList events={data} eventsHandler={eventsHandler} />
{status === 'success' && events != null ? (
<EventList events={events} eventsHandler={eventsHandler} />
) : (
<Empty text='Connecting to server' />
)}
+21 -2
View File
@@ -33,14 +33,33 @@ export default function MenuBar(props) {
const handleUpload = (event) => {
const fileUploaded = event.target.files[0];
if (fileUploaded == null) return;
console.log(fileUploaded);
// Limit file size to 1MB
if (fileUploaded.size > 1000000) {
console.log('Error: File size limit (1MB) exceeded');
return;
}
// Check file extension
if (fileUploaded.name.endsWith('.xlsx')) {
console.log('excel file');
} else if (fileUploaded.name.endsWith('.json')) {
console.log('json file');
} else {
console.log('Error: File type unknown');
return;
}
try {
uploaddb.mutate(fileUploaded);
} catch (error) {
console.log(error);
}
// reset input value
hiddenFileInput.current.value = '';
};
const handleIPC = (action) => {
@@ -102,7 +121,7 @@ export default function MenuBar(props) {
style={{ display: 'none' }}
ref={hiddenFileInput}
onChange={handleUpload}
accept='.json'
accept='.json, .xlsx'
/>
<UploadIconBtn
style={{ fontSize: '1.5em' }}
+1 -1
View File
@@ -111,7 +111,7 @@
"title": "All about Carlos demo event",
"url": "www.carlosvalente.com",
"publicInfo": "WiFi: demoproject \nPassword: ontimeproject",
"backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject"
"backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject",
"endMessage": ""
},
"settings": {
+17 -7
View File
@@ -26,22 +26,33 @@ import { Client } from 'node-osc';
import express from 'express';
import http from 'http';
import cors from 'cors';
import { dbModel } from './models/dataModel.js';
import { dbModelv1 as dbModel } from './models/dataModel.js';
import { parseJsonv1 as parseJson, validateFile } from './utils/parser.js';
import ua from 'universal-analytics';
// Read data from JSON file, this will set db.data content
await db.read();
// validate JSON before attempting read
let isValid = validateFile(file);
if (isValid) {
console.log('reading this');
// Read data from JSON file, this will set db.data content
await db.read();
}
// If file.json doesn't exist, db.data will be null
// Set default data
// db.data ||= { events: [] }; NODE v15 - v16
if (db.data == null) {
if (db.data == null || !isValid) {
db.data = dbModel;
db.write();
await db.write();
}
// get data
export const data = db.data;
// there is also the case of the db being corrupt
// try to parse the data
export const data = await parseJson(db.data);
db.data = data;
await db.write();
// Import Routes
import { router as eventsRouter } from './routes/eventsRouter.js';
@@ -107,7 +118,6 @@ app.use((err, req, res, next) => {
*/
const s = data.settings;
const oscIP = s.oscOutIP || config.osc.ipOut;
const oscOutPort = s.oscOutPort || config.osc.portOut;
const oscInPort = s.oscInPort || config.osc.port;
+2 -3
View File
@@ -2,8 +2,7 @@
import { db, data } from '../app.js';
// utils
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('1234567890abcdef', 4);
import { generateId } from '../utils/generate_id.js';
import {
event as eventDef,
delay as delayDef,
@@ -98,7 +97,7 @@ export const eventsPost = async (req, res) => {
// ensure structure
let newEvent = {};
req.body.id = nanoid();
req.body.id = generateId();
switch (req.body.type) {
case 'event':
+24 -112
View File
@@ -4,13 +4,8 @@ import { fileURLToPath } from 'url';
// get database
import { db, data } from '../app.js';
import {
event as eventDef,
delay as delayDef,
block as blockDef,
} from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -19,93 +14,6 @@ function getEventTitle() {
return data.event.title;
}
async function deleteFile(file) {
// delete a file
fs.unlink(file, (err) => {
if (err) {
console.log(err);
}
});
}
// parses version 1 of the data system
async function parsev1(jsonData) {
let numEntries = 0;
if ('events' in jsonData) {
console.log('Found events definition, importing...');
let events = [];
let ids = [];
for (const e of jsonData.events) {
if (e.type === 'event') {
// doublecheck unique ids
if (e.id == null || ids.indexOf(e.id) !== -1) continue;
ids.push(e.id);
// make sure all properties exits
// dont load any extra properties than the ones known
events.push({
...eventDef,
title: e.title,
subtitle: e.subtitle,
presenter: e.presenter,
note: e.note,
timeStart: e.timeStart,
timeEnd: e.timeEnd,
isPublic: e.isPublic,
id: e.id,
});
numEntries++;
} else if (e.type === 'delay') {
events.push({ ...delayDef, duration: e.duration });
numEntries++;
} else if (e.type === 'block') {
events.push({ ...blockDef });
numEntries++;
}
}
// write to db
db.data.events = events;
db.write();
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 = {
...dbModel.event,
title: e.title,
url: e.url,
publicInfo: e.publicInfo,
backstageInfo: e.backstageInfo,
endMessage: e.endMessage,
};
// write to db
db.data.event = event;
db.write();
}
// Settings handled partially
if ('settings' in jsonData) {
console.log('Found settings definition, importing...');
const s = jsonData.settings;
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
db.data.settings = {
...dbModel.settings,
...settings,
};
db.write();
}
}
// Create controller for GET request to '/ontime/db'
// Returns -
export const dbDownload = async (req, res) => {
@@ -121,6 +29,10 @@ export const dbDownload = async (req, res) => {
});
};
/**
* @description Controller for POST request to /ontime/db
* @returns none
*/
const upload = async (file, req, res) => {
if (!fs.existsSync(file)) {
res.status(500).send({ message: 'Upload failed' });
@@ -128,30 +40,30 @@ const upload = async (file, req, res) => {
}
try {
// get file
const rawdata = fs.readFileSync(file);
const uploadedJson = JSON.parse(rawdata);
const result = await fileHandler(file);
// delete file
deleteFile(file);
// check version
if (uploadedJson.settings.version === 1) {
try {
parsev1(uploadedJson);
global.timer.setupWithEventList(db.data.events);
} catch (error) {
res.status(400).send({ message: `Error parsing file: ${error}` });
if (result?.error) {
res.status(400).send({ message: result.message });
} else if (result.message === 'success') {
if (result.data != null) {
if (result.data?.events != null) {
data.events = result.data.events;
global.timer.setupWithEventList(result.data?.events);
}
if (result.data?.event != null) {
data.event = result.data.event;
}
if (result.data?.settings != null) {
data.settings = result.data.settings;
}
db.write();
}
res.sendStatus(200);
} else {
res.status(400).send({ message: 'Error parsing file, version unknown' });
return;
res.status(400).send({ message: 'Failed parsing, no data' });
}
res.sendStatus(200);
} catch (error) {
console.log('Error parsing file', error);
res.status(400).send({ message: error });
res.status(400).send({ message: `Failed parsing ${error}` });
}
};
+1 -1
View File
@@ -1,4 +1,4 @@
export const dbModel = {
export const dbModelv1 = {
events: [],
event: {
title: '',
+1
View File
@@ -9,6 +9,7 @@
"multer": "^1.4.2",
"nanoid": "^3.1.22",
"node-osc": "6.1.10",
"node-xlsx": "^0.17.2",
"passport": "~0.4.1",
"passport-local": "~1.0.0",
"socket.io": "^4.3.1",
+3 -3
View File
@@ -1,5 +1,5 @@
import express from 'express';
import uploadJson from '../utils/upload.js';
import { uploadFile } from '../utils/upload.js';
export const router = express.Router();
import {
@@ -14,10 +14,10 @@ import {
router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint
router.post('/db', uploadJson, dbUpload);
router.post('/db', uploadFile, dbUpload);
// create route between controller and '/ontime/info' endpoint
router.get('/info', uploadJson, getInfo);
router.get('/info', uploadFile, getInfo);
// create route between controller and '/ontime/info' endpoint
router.post('/info', postInfo);
@@ -0,0 +1,20 @@
import { generateId } from '../generate_id.js';
describe('generate a valid id', () => {
it('generates a 5 digit id', () => {
const id = generateId();
expect(id.length).toBe(5);
});
});
describe('generate 100', () => {
it('all ids are unique', () => {
let ids = [];
for (let i = 0; i < 100; i++) {
ids.push(generateId());
}
const unique = [...new Set(ids)];
expect(ids.length).toBe(unique.length);
});
});
+507
View File
@@ -0,0 +1,507 @@
import jest from 'jest-mock';
import {
makeString,
parseExcelv1,
parseJsonv1,
validateEventv1,
} from '../parser.js';
import { dbModelv1 as dbModel } from '../../models/dataModel.js';
import { describe } from 'jest-circus';
describe('test json parser with valid def', () => {
const testData = {
events: [
{
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
isPublic: false,
type: 'event',
revision: 0,
id: '4b31',
},
{
title: 'Good Morning',
subtitle: 'Days schedule',
presenter: 'Carlos Valente',
note: '',
timeStart: 32400000,
timeEnd: 36000000,
isPublic: true,
type: 'event',
revision: 0,
id: 'f24d',
},
{
title: 'Stage 2 setup',
subtitle: '',
presenter: '',
note: '',
timeStart: 32400000,
timeEnd: 37200000,
isPublic: false,
type: 'event',
revision: 0,
id: 'bbc5',
},
{
title: 'Working Procedures',
subtitle: '',
presenter: 'Filip Johansen',
note: '',
timeStart: 37200000,
timeEnd: 39000000,
isPublic: true,
type: 'event',
revision: 0,
id: '5b3e',
},
{
title: 'Lunch',
subtitle: '',
presenter: '',
note: '',
timeStart: 39600000,
timeEnd: 45000000,
isPublic: false,
type: 'event',
revision: 0,
id: '8e2c',
},
{
title: 'A day being carlos',
subtitle: 'My life in a song',
presenter: 'Carlos Valente',
note: '',
timeStart: 46800000,
timeEnd: 50400000,
isPublic: true,
type: 'event',
revision: 0,
id: '08e9',
},
{
title: 'Hamburgers and Cheese',
subtitle: '... and other life questions',
presenter: 'Filip Johansen',
note: '',
timeStart: 54000000,
timeEnd: 57600000,
isPublic: true,
type: 'event',
revision: 0,
id: 'e25a',
},
],
event: {
title: 'This is a test definition',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
},
settings: {
app: 'ontime',
version: 1,
},
};
let parseResponse;
beforeEach(async () => {
parseResponse = await parseJsonv1(testData);
});
it('has 7 events', () => {
const length = parseResponse?.events.length;
expect(length).toBe(7);
});
it('first event is as a match', () => {
const first = parseResponse?.events[0];
const expected = {
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
isPublic: false,
type: 'event',
revision: 0,
id: '4b31',
};
});
it('loaded event settings', () => {
const eventTitle = parseResponse?.event?.title;
expect(eventTitle).toBe('This is a test definition');
});
it('endMessage to exist but be empty', () => {
const endMessage = parseResponse?.event?.endMessage;
expect(endMessage).toBeDefined();
expect(endMessage).toBe('');
});
it('settings are for right app and version', () => {
const settings = parseResponse?.settings;
expect(settings.app).toBe('ontime');
expect(settings.version).toBe(1);
});
it('missing settings', () => {
const settings = parseResponse?.settings;
expect(settings.osc_port).toBeUndefined();
});
});
describe('test parser edge cases', () => {
it('generates missing ids', async () => {
const testData = {
events: [
{
title: 'Test Event',
type: 'event',
},
],
};
const parseResponse = await parseJsonv1(testData);
expect(parseResponse.events[0].id).toBeDefined();
});
it('detects duplicate Ids', async () => {
console.log = jest.fn();
const testData = {
events: [
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '1',
},
],
};
const parseResponse = await parseJsonv1(testData);
expect(console.log).toHaveBeenCalledWith(
'ERROR: ID colision on import, skipping'
);
expect(parseResponse?.events.length).toBe(1);
});
it('handles incomplete datasets', async () => {
console.log = jest.fn();
const testData = {
events: [
{
title: 'Test Event 1',
id: '1',
},
{
title: 'Test Event 2',
id: '1',
},
],
};
const parseResponse = await parseJsonv1(testData);
expect(console.log).toHaveBeenCalledWith(
'ERROR: undefined event type, skipping'
);
expect(parseResponse?.events.length).toBe(0);
});
it('skips unknown app and version settings', async () => {
console.log = jest.fn();
const testData = {
settings: {
osc_port: 8888,
},
};
await parseJsonv1(testData);
expect(console.log).toHaveBeenCalledWith(
'ERROR: unknown app version, skipping'
);
});
});
describe('test corrupt data', () => {
it('handles some empty events', async () => {
const emptyEvents = {
events: [
{},
{},
{},
{
title: 'Test Event 1',
type: 'event',
id: '1',
},
{
title: 'Test Event 2',
type: 'event',
id: '2',
},
{},
{},
{},
],
event: {
title: 'All about Carlos demo event',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
oscInPort: 8888,
oscOutPort: 9999,
oscOutIP: '127.0.0.1',
oscEnabled: false,
lock: false,
},
};
const parsedDef = await parseJsonv1(emptyEvents);
expect(parsedDef.events.length).toBe(2);
});
it('handles all empty events', async () => {
const emptyEvents = {
events: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {
title: 'All about Carlos demo event',
url: 'www.carlosvalente.com',
publicInfo: 'WiFi: demoproject \nPassword: ontimeproject',
backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject',
endMessage: '',
},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
oscInPort: 8888,
oscOutPort: 9999,
oscOutIP: '127.0.0.1',
oscEnabled: false,
lock: false,
},
};
const parsedDef = await parseJsonv1(emptyEvents);
expect(parsedDef.events.length).toBe(0);
});
it('handles missing event data', async () => {
const emptyEventData = {
events: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
settings: {
app: 'ontime',
version: 1,
serverPort: 4001,
oscInPort: 8888,
oscOutPort: 9999,
oscOutIP: '127.0.0.1',
oscEnabled: false,
lock: false,
},
};
const parsedDef = await parseJsonv1(emptyEventData);
expect(parsedDef.event).toStrictEqual(dbModel.event);
});
it('handles missing settings', async () => {
const missingSettings = {
events: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
settings: {
app: 'ontime',
version: 1,
},
};
const parsedDef = await parseJsonv1(missingSettings);
expect(parsedDef.settings).toStrictEqual(dbModel.settings);
});
it('fails with invalid JSON', async () => {
console.log = jest.fn();
const invalidJSON = 'some random dataset';
const parsedDef = await parseJsonv1(invalidJSON);
expect(console.log).toHaveBeenCalledWith('ERROR: Invalid JSON format');
expect(parsedDef).toBe(-1);
});
});
describe('test event validator', () => {
it('validates a good object', () => {
const event = {
title: 'test',
};
const validated = validateEventv1(event);
expect(validated).toEqual(
expect.objectContaining({
title: expect.any(String),
subtitle: expect.any(String),
presenter: expect.any(String),
note: expect.any(String),
timeStart: expect.any(Number),
timeEnd: expect.any(Number),
isPublic: expect.any(Boolean),
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
})
);
});
it('fails an empty object', () => {
const event = {};
const validated = validateEventv1(event);
expect(validated).toEqual(null);
});
it('makes objects strings', () => {
const event = {
title: 2,
subtitle: true,
presenter: 3.2,
note: '1899-12-30T08:00:10.000Z',
};
const validated = validateEventv1(event);
expect(typeof validated.title).toEqual('string');
expect(typeof validated.subtitle).toEqual('string');
expect(typeof validated.presenter).toEqual('string');
expect(typeof validated.note).toEqual('string');
});
it('enforces numbers on times', () => {
const event = {
timeStart: false,
timeEnd: '2',
};
const validated = validateEventv1(event);
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(0);
});
it('handles bad objects', () => {
const event = {
title: {},
};
const validated = validateEventv1(event);
expect(typeof validated.title).toEqual('string');
});
});
describe('test makeString function', () => {
it('converts variables to string', () => {
let val = 2;
let expected = '2';
let converted = makeString(val);
expect(converted).toBe(expected);
val = 2.22222222;
expected = '2.22222222';
converted = makeString(val);
expect(converted).toBe(expected);
val = ['testing'];
expected = 'testing';
converted = makeString(val);
expect(converted).toBe(expected);
val = { doing: 'testing' };
expected = 'testing';
converted = makeString(val, 'fallback');
expect(converted).toBe('fallback');
});
});
describe('test parseExcel function', () => {
it('parses the example file', async () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
['Event Name', 'Test Event'],
['Event URL', 'www.carlosvalente.com'],
[],
[],
[
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Is Public? (x)',
'Notes',
],
[
'1899-12-30T07:00:00.000Z',
'1899-12-30T08:00:10.000Z',
'Guest Welcome',
'Carlos',
'Getting things started',
'x',
'Ballyhoo',
],
[
'1899-12-30T08:00:00.000Z',
'1899-12-30T08:30:00.000Z',
'A song from the hearth',
'Still Carlos',
'Derailing early',
'',
'Rainbow chase',
],
[],
];
const expectedParsedEvents = [
{
timeStart: 28800000,
timeEnd: 32410000,
title: 'Guest Welcome',
presenter: 'Carlos',
subtitle: 'Getting things started',
isPublic: true,
note: 'Ballyhoo',
type: 'event',
},
{
timeStart: 32400000,
timeEnd: 34200000,
title: 'A song from the hearth',
presenter: 'Still Carlos',
subtitle: 'Derailing early',
isPublic: false,
note: 'Rainbow chase',
type: 'event',
},
];
const parsedData = await parseExcelv1(testdata);
expect(parsedData.events).toBeDefined();
expect(parsedData.events).toStrictEqual(expectedParsedEvents);
expect(parsedData.events).toStrictEqual(expectedParsedEvents);
});
});
+20 -1
View File
@@ -1,4 +1,5 @@
import { stringFromMillis } from '../time.js';
import { describe } from 'jest-circus';
import { excelDateStringToMillis, stringFromMillis } from '../time.js';
describe('test string to milis function', () => {
it('test with null values', () => {
@@ -56,3 +57,21 @@ describe('test string to milis function', () => {
expect(stringFromMillis(t.val)).toBe(t.result);
});
});
describe('test excel date parser', () => {
it('parses the given dates correctly', () => {
const d1 = '1899-12-30T08:00:00.000Z';
const d2 = '1899-12-30T08:30:00.000Z';
const d1Millis = 28800000;
const d2Millis = 30600000;
expect(excelDateStringToMillis(d1)).toBe(d1Millis);
expect(excelDateStringToMillis(d2)).toBe(d2Millis);
});
it.only('handles an invalid date string', () => {
const s = 'hello';
expect(excelDateStringToMillis(s)).toBe(null);
});
});
+4
View File
@@ -0,0 +1,4 @@
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('1234567890abcdef', 5);
export const generateId = () => nanoid();
+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;
}
};
+25 -3
View File
@@ -1,3 +1,7 @@
const mts = 1000; // millis to seconds
const mtm = 1000 * 60; // millis to minutes
const mth = 1000 * 60 * 60; // millis to hours
/**
* @description Converts milliseconds to string representing time
* @param {number} ms - time in milliseconds
@@ -18,9 +22,9 @@ export const stringFromMillis = (
const millis = Math.abs(ms);
const showWith0 = (value) => (value < 10 ? `0${value}` : value);
const hours = showWith0(Math.floor(((millis / (1000 * 60 * 60)) % 60) % 24));
const minutes = showWith0(Math.floor((millis / (1000 * 60)) % 60));
const seconds = showWith0(Math.floor((millis / 1000) % 60));
const hours = showWith0(Math.floor(((millis / mth) % 60) % 24));
const minutes = showWith0(Math.floor((millis / mtm) % 60));
const seconds = showWith0(Math.floor((millis / mts) % 60));
return showSeconds
? `${isNegative}${
@@ -28,3 +32,21 @@ export const stringFromMillis = (
}${minutes}${delim}${seconds}`
: `${isNegative}${parseInt(hours) ? `${hours}` : '00'}${delim}${minutes}`;
};
/**
* @description Converts an excel date to milliseconds
* @argument {string} excelDate - excel string date
* @returns {number} - time in millisenconds
*/
export const excelDateStringToMillis = (excelDate) => {
const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date)) {
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return h * mth + m * mtm + s * mts;
}
return null;
};
+16 -9
View File
@@ -1,6 +1,8 @@
import multer from 'multer';
import { statSync, mkdirSync } from 'fs';
import { EXCEL_MIME, JSON_MIME } from './parser.js';
// Define multer storage object
const storage = multer.diskStorage({
destination: function (req, file, cb) {
let newDestination = 'uploads/';
@@ -12,9 +14,7 @@ const storage = multer.diskStorage({
}
if (stat && !stat.isDirectory()) {
throw new Error(
'Directory cannot be created because an inode of a different type exists at "' +
dest +
'"'
`Directory cannot be created because an inode of a different type exists at ${newDestination}`
);
}
cb(null, newDestination);
@@ -24,15 +24,22 @@ const storage = multer.diskStorage({
},
});
// filter only json
const filterJson = (req, file, cb) => {
if (file.mimetype.includes('application/json')) {
/**
* @description Middleware function to filter allowed file types
* @argument file - reference to file
* @return {boolean} - file allowed
*/
const filterAllowed = (req, file, cb) => {
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
cb(null, true);
} else {
console.log('ERROR: Unrecognised file type');
cb(null, false);
}
};
const uploadJson = multer({ storage: storage, fileFilter: filterJson });
export default uploadJson.single('jsondb');
// Build multer uploader for a single file
export const uploadFile = multer({
storage: storage,
fileFilter: filterAllowed,
}).single('userFile');
+109 -1
View File
@@ -38,6 +38,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.15.4":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
dependencies:
regenerator-runtime "^0.13.4"
"@eslint/eslintrc@^0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
@@ -110,6 +117,21 @@ acorn@^7.4.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
adler-32@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.2.0.tgz#6a3e6bf0a63900ba15652808cb15c6813d1a5f25"
integrity sha1-aj5r8KY5ALoVZSgIyxXGgT0aXyU=
dependencies:
exit-on-epipe "~1.0.1"
printj "~1.1.0"
adler-32@~1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.0.tgz#3cad1b71cdfa69f6c8a91f3e3615d31a4fdedc72"
integrity sha512-f5nltvjl+PRUh6YNfUstRaXwJxtfnKEWhAWWlmKvh+Y3J2+98a0KKVYDEhz6NdKGqswLhjNGznxfSsZGOvOd9g==
dependencies:
printj "~1.2.2"
ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
version "6.12.6"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -306,7 +328,7 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0"
concat-map "0.0.1"
buffer-from@^1.0.0:
buffer-from@^1.0.0, buffer-from@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
@@ -342,6 +364,15 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
cfb@^1.1.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.1.tgz#209429e4c68efd30641f6fc74b2d6028bd202402"
integrity sha512-wT2ScPAFGSVy7CY+aauMezZBnNrfnaLSrxHUHdea+Td/86vrk6ZquggV+ssBR88zNs0OnBkL2+lf9q0K+zVGzQ==
dependencies:
adler-32 "~1.3.0"
crc-32 "~1.2.0"
printj "~1.3.0"
chalk@^2.0.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
@@ -359,6 +390,11 @@ chalk@^4.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
codepage@~1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab"
integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
color-convert@^1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
@@ -465,6 +501,14 @@ cors@~2.8.5:
object-assign "^4"
vary "^1"
crc-32@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.0.tgz#cb2db6e29b88508e32d9dd0ec1693e7b41a18208"
integrity sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==
dependencies:
exit-on-epipe "~1.0.1"
printj "~1.1.0"
cross-spawn@^7.0.2:
version "7.0.3"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -894,6 +938,11 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
exit-on-epipe@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz#0bdd92e87d5285d267daa8171d0eb06159689692"
integrity sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==
express-session@~1.17.1:
version "1.17.2"
resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.2.tgz#397020374f9bf7997f891b85ea338767b30d0efd"
@@ -1033,6 +1082,11 @@ forwarded@0.2.0:
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
frac@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b"
integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
@@ -1599,6 +1653,15 @@ node-osc@6.1.10:
dependencies:
osc-min "^1.1.1"
node-xlsx@^0.17.2:
version "0.17.2"
resolved "https://registry.yarnpkg.com/node-xlsx/-/node-xlsx-0.17.2.tgz#286215afa63e096d5317a7cb0e5d599cfa1f7b62"
integrity sha512-j92dGS8KvGPi6YpYovHrR9zWiyDONx7DiGhl1SjM+vzxAh3do6hmerFCyN+hRuK7QhwHdwzfpYxZm+hKA/uErA==
dependencies:
"@babel/runtime" "^7.15.4"
buffer-from "^1.1.2"
xlsx "^0.17.2"
oauth-sign@~0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
@@ -1800,6 +1863,21 @@ prelude-ls@^1.2.1:
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
printj@~1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/printj/-/printj-1.1.2.tgz#d90deb2975a8b9f600fb3a1c94e3f4c53c78a222"
integrity sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==
printj@~1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/printj/-/printj-1.2.3.tgz#2cfb2b192a1e5385dbbe5b46658ac34aa828508a"
integrity sha512-sanczS6xOJOg7IKDvi4sGOUOe7c1tsEzjwlLFH/zgwx/uyImVM9/rgBkc8AfiQa/Vg54nRd8mkm9yI7WV/O+WA==
printj@~1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/printj/-/printj-1.3.0.tgz#9018a918a790e43707f10625d6e10187a367cff6"
integrity sha512-017o8YIaz8gLhaNxRB9eBv2mWXI2CtzhPJALnQTP+OPpuUfP0RMWqr/mHCzqVeu1AQxfzSfAtAq66vKB8y7Lzg==
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -2094,6 +2172,13 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
ssf@~0.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c"
integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
dependencies:
frac "~1.1.2"
sshpk@^1.7.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@@ -2373,11 +2458,21 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
wmf@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da"
integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
word-wrap@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
word@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961"
integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@@ -2388,6 +2483,19 @@ ws@~8.2.3:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba"
integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==
xlsx@^0.17.2:
version "0.17.4"
resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.17.4.tgz#dc3e3a0954c835f4d0fdd643645db6f4ac3f28f2"
integrity sha512-9aKt8g9ZLP0CUdBX8L5xnoMDFwSiLI997eQnDThCaqQMYB9AEBIRzblSSNN/ICMGLYIHUO3VKaItcedZJ3ijIg==
dependencies:
adler-32 "~1.2.0"
cfb "^1.1.4"
codepage "~1.15.0"
crc-32 "~1.2.0"
ssf "~0.11.2"
wmf "~1.0.1"
word "~0.3.0"
xtend@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"