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"