From 8c740b7111ff2569d1023f29c91bdf1260b4b84c Mon Sep 17 00:00:00 2001
From: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
Date: Wed, 24 Nov 2021 22:07:31 +0100
Subject: [PATCH] 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
---
client/package.json | 2 +-
client/src/App.jsx | 35 +-
client/src/app/api/ontimeApi.js | 2 +-
.../errorBoundary/ErrorBoundary.jsx | 35 ++
client/src/features/editors/Editor.jsx | 21 +-
.../editors/list/EventListWrapper.jsx | 13 +-
client/src/features/menu/MenuBar.jsx | 23 +-
server/data/db.json | 2 +-
server/src/app.js | 24 +-
server/src/controllers/eventsController.js | 5 +-
server/src/controllers/ontimeController.js | 136 +----
server/src/models/dataModel.js | 2 +-
server/src/package.json | 1 +
server/src/routes/ontimeRouter.js | 6 +-
.../src/utils/__tests__/generate_id.test.js | 20 +
server/src/utils/__tests__/parser.tests.js | 507 ++++++++++++++++++
server/src/utils/__tests__/time.tests.js | 21 +-
server/src/utils/generate_id.js | 4 +
server/src/utils/parser.js | 369 +++++++++++++
server/src/utils/time.js | 28 +-
server/src/utils/upload.js | 25 +-
server/src/yarn.lock | 110 +++-
22 files changed, 1222 insertions(+), 169 deletions(-)
create mode 100644 client/src/common/components/errorBoundary/ErrorBoundary.jsx
create mode 100644 server/src/utils/__tests__/generate_id.test.js
create mode 100644 server/src/utils/__tests__/parser.tests.js
create mode 100644 server/src/utils/generate_id.js
create mode 100644 server/src/utils/parser.js
diff --git a/client/package.json b/client/package.json
index 29f0fee87..d1d214bae 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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"
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 6899e99a7..b3af1e576 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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() {
-
-
-
-
-
-
-
-
-
-
- {/* Lower cannot have fallback */}
-
- {/* Send to default if nothing found */}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ {/* Lower cannot have fallback */}
+
+ {/* Send to default if nothing found */}
+
+
+
+
diff --git a/client/src/app/api/ontimeApi.js b/client/src/app/api/ontimeApi.js
index d5fd4e568..9d850259c 100644
--- a/client/src/app/api/ontimeApi.js
+++ b/client/src/app/api/ontimeApi.js
@@ -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: {
diff --git a/client/src/common/components/errorBoundary/ErrorBoundary.jsx b/client/src/common/components/errorBoundary/ErrorBoundary.jsx
new file mode 100644
index 000000000..c97221ee1
--- /dev/null
+++ b/client/src/common/components/errorBoundary/ErrorBoundary.jsx
@@ -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
:/
;
+ }
+ return this.props.children;
+ }
+}
+
+export default ErrorBoundary;
diff --git a/client/src/features/editors/Editor.jsx b/client/src/features/editors/Editor.jsx
index 46e950636..27047811a 100644
--- a/client/src/features/editors/Editor.jsx
+++ b/client/src/features/editors/Editor.jsx
@@ -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() {
-
+
+
+
Event List
-
+
+
+
Display Messages
-
+
+
+
Timer Control
Info
-
+
+
+
diff --git a/client/src/features/editors/list/EventListWrapper.jsx b/client/src/features/editors/list/EventListWrapper.jsx
index 7dcfdb426..86befdc66 100644
--- a/client/src/features/editors/list/EventListWrapper.jsx
+++ b/client/src/features/editors/list/EventListWrapper.jsx
@@ -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 (
<>
- {status === 'success' ? (
-
+ {status === 'success' && events != null ? (
+
) : (
)}
diff --git a/client/src/features/menu/MenuBar.jsx b/client/src/features/menu/MenuBar.jsx
index 6acb50de3..4bfe0f7fd 100644
--- a/client/src/features/menu/MenuBar.jsx
+++ b/client/src/features/menu/MenuBar.jsx
@@ -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'
/>
{
*/
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;
diff --git a/server/src/controllers/eventsController.js b/server/src/controllers/eventsController.js
index 47fc27950..a990d216b 100644
--- a/server/src/controllers/eventsController.js
+++ b/server/src/controllers/eventsController.js
@@ -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':
diff --git a/server/src/controllers/ontimeController.js b/server/src/controllers/ontimeController.js
index 8b0657d94..1b0258b61 100644
--- a/server/src/controllers/ontimeController.js
+++ b/server/src/controllers/ontimeController.js
@@ -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}` });
}
};
diff --git a/server/src/models/dataModel.js b/server/src/models/dataModel.js
index ef14825b7..0e012ad6b 100644
--- a/server/src/models/dataModel.js
+++ b/server/src/models/dataModel.js
@@ -1,4 +1,4 @@
-export const dbModel = {
+export const dbModelv1 = {
events: [],
event: {
title: '',
diff --git a/server/src/package.json b/server/src/package.json
index 2379827a7..1ed93838b 100644
--- a/server/src/package.json
+++ b/server/src/package.json
@@ -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",
diff --git a/server/src/routes/ontimeRouter.js b/server/src/routes/ontimeRouter.js
index ba87f9f93..1aa8975b2 100644
--- a/server/src/routes/ontimeRouter.js
+++ b/server/src/routes/ontimeRouter.js
@@ -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);
diff --git a/server/src/utils/__tests__/generate_id.test.js b/server/src/utils/__tests__/generate_id.test.js
new file mode 100644
index 000000000..0751f778b
--- /dev/null
+++ b/server/src/utils/__tests__/generate_id.test.js
@@ -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);
+ });
+});
diff --git a/server/src/utils/__tests__/parser.tests.js b/server/src/utils/__tests__/parser.tests.js
new file mode 100644
index 000000000..8244a617c
--- /dev/null
+++ b/server/src/utils/__tests__/parser.tests.js
@@ -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);
+ });
+});
diff --git a/server/src/utils/__tests__/time.tests.js b/server/src/utils/__tests__/time.tests.js
index 7f32020cf..5c576c9ea 100644
--- a/server/src/utils/__tests__/time.tests.js
+++ b/server/src/utils/__tests__/time.tests.js
@@ -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);
+ });
+});
diff --git a/server/src/utils/generate_id.js b/server/src/utils/generate_id.js
new file mode 100644
index 000000000..d3b245b45
--- /dev/null
+++ b/server/src/utils/generate_id.js
@@ -0,0 +1,4 @@
+import { customAlphabet } from 'nanoid';
+const nanoid = customAlphabet('1234567890abcdef', 5);
+
+export const generateId = () => nanoid();
diff --git a/server/src/utils/parser.js b/server/src/utils/parser.js
new file mode 100644
index 000000000..c4fc021c4
--- /dev/null
+++ b/server/src/utils/parser.js
@@ -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;
+ }
+};
diff --git a/server/src/utils/time.js b/server/src/utils/time.js
index e0ce041ef..16fa9a1b5 100644
--- a/server/src/utils/time.js
+++ b/server/src/utils/time.js
@@ -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;
+};
diff --git a/server/src/utils/upload.js b/server/src/utils/upload.js
index d58703050..04f8bc0d6 100644
--- a/server/src/utils/upload.js
+++ b/server/src/utils/upload.js
@@ -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');
diff --git a/server/src/yarn.lock b/server/src/yarn.lock
index ac6ef628d..6be3babc9 100644
--- a/server/src/yarn.lock
+++ b/server/src/yarn.lock
@@ -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"