mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 03:13:47 +00:00
Feat/excel (#45)
* add dependency adding dependency to parse the excel file in the node side * import file ability to import file - add extension - safeguard file type and size * user is able to upload json or excel * chore: extract logic into self contained function * associate model with version * test id generation * parse excel data * prevent UI crash on bad data * extract validation into self contained function * chore: add more tests for bad data * config: stop from opening browser on start * fix comma in sample db * handle corrupt files * error boundary around main components * jsdocs * increase id length * parse excel time string * feat: excel parsing * validate json db before import * chore: test event validator * feat: make function to clean convert strings * chore: test parser * chore: jsdocs * fix: bug on upload prevent bug where the component would prevent upload of same file twice
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@
|
||||
"web-vitals": "^1.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"start": "set BROWSER=none&&react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
|
||||
+19
-16
@@ -4,6 +4,7 @@ import './App.css';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import SocketProvider from 'app/context/socketContext';
|
||||
import withSocket from 'features/viewers/ViewWrapper';
|
||||
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
||||
|
||||
const Editor = lazy(() => import('features/editors/Editor'));
|
||||
const PresenterView = lazy(() =>
|
||||
@@ -65,22 +66,24 @@ function App() {
|
||||
<SocketProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className='App'>
|
||||
<Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route exact path='/' component={SSpeaker} />
|
||||
<Route exact path='/sm' component={SStageManager} />
|
||||
<Route exact path='/speaker' component={SSpeaker} />
|
||||
<Route exact path='/stage' component={SSpeaker} />
|
||||
<Route exact path='/speakersimple' component={SSpeakerSimple} />
|
||||
<Route exact path='/editor' component={Editor} />
|
||||
<Route exact path='/public' component={SPublic} />
|
||||
<Route exact path='/pip' component={SPip} />
|
||||
{/* Lower cannot have fallback */}
|
||||
<Route exact path='/lower' component={SLowerThird} />
|
||||
{/* Send to default if nothing found */}
|
||||
<Route component={SSpeaker} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={null}>
|
||||
<Switch>
|
||||
<Route exact path='/' component={SSpeaker} />
|
||||
<Route exact path='/sm' component={SStageManager} />
|
||||
<Route exact path='/speaker' component={SSpeaker} />
|
||||
<Route exact path='/stage' component={SSpeaker} />
|
||||
<Route exact path='/speakersimple' component={SSpeakerSimple} />
|
||||
<Route exact path='/editor' component={Editor} />
|
||||
<Route exact path='/public' component={SPublic} />
|
||||
<Route exact path='/pip' component={SPip} />
|
||||
{/* Lower cannot have fallback */}
|
||||
<Route exact path='/lower' component={SLowerThird} />
|
||||
{/* Send to default if nothing found */}
|
||||
<Route component={SSpeaker} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</SocketProvider>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const downloadEvents = async () => {
|
||||
|
||||
export const uploadEvents = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('jsondb', file); // appending file
|
||||
formData.append('userFile', file); // appending file
|
||||
await axios
|
||||
.post(ontimeURL + '/db', formData, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so next render shows fallback UI.
|
||||
return { errorMessage: error.toString() };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: info,
|
||||
});
|
||||
// TODO: Log the error to an error reporting service
|
||||
this.logErrorToServices(error.toString(), info.componentStack);
|
||||
}
|
||||
|
||||
// A fake logging service.
|
||||
logErrorToServices = console.log;
|
||||
|
||||
render() {
|
||||
if (this.state.errorMessage) {
|
||||
// You can render any custom fallback UI
|
||||
return <p>:/</p>;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -4,6 +4,7 @@ import { useDisclosure } from '@chakra-ui/hooks';
|
||||
import styles from './Editor.module.css';
|
||||
import MenuBar from 'features/menu/MenuBar';
|
||||
import ModalManager from 'features/modals/ModalManager';
|
||||
import ErrorBoundary from 'common/components/errorBoundary/ErrorBoundary';
|
||||
|
||||
const EventListWrapper = lazy(() =>
|
||||
import('features/editors/list/EventListWrapper')
|
||||
@@ -26,34 +27,44 @@ export default function Editor() {
|
||||
|
||||
<div className={styles.mainContainer}>
|
||||
<Box id='settings' className={styles.settings}>
|
||||
<MenuBar onOpen={onOpen} />
|
||||
<ErrorBoundary>
|
||||
<MenuBar onOpen={onOpen} />
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.editor}>
|
||||
<h1>Event List</h1>
|
||||
<div className={styles.content}>
|
||||
<EventListWrapper />
|
||||
<ErrorBoundary>
|
||||
<EventListWrapper />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.messages}>
|
||||
<h1>Display Messages</h1>
|
||||
<div className={styles.content}>
|
||||
<MessageControl />
|
||||
<ErrorBoundary>
|
||||
<MessageControl />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.playback}>
|
||||
<h1>Timer Control</h1>
|
||||
<div className={styles.content}>
|
||||
<PlaybackControl />
|
||||
<ErrorBoundary>
|
||||
<PlaybackControl />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.info}>
|
||||
<h1>Info</h1>
|
||||
<div className={styles.content}>
|
||||
<Info />
|
||||
<ErrorBoundary>
|
||||
<Info />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
fetchAllEvents,
|
||||
requestPatch,
|
||||
@@ -26,6 +26,7 @@ export default function EventListWrapper() {
|
||||
EVENTS_TABLE,
|
||||
fetchAllEvents
|
||||
);
|
||||
const [events, setEvents] = useState(null);
|
||||
|
||||
const addEvent = useMutation(requestPost, {
|
||||
// we optimistically update here
|
||||
@@ -327,11 +328,17 @@ export default function EventListWrapper() {
|
||||
[data]
|
||||
);
|
||||
|
||||
// Front end should handle bad arguments
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
setEvents(data.filter((d) => Object.keys(d).length > 0));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EventListMenu eventsHandler={eventsHandler} />
|
||||
{status === 'success' ? (
|
||||
<EventList events={data} eventsHandler={eventsHandler} />
|
||||
{status === 'success' && events != null ? (
|
||||
<EventList events={events} eventsHandler={eventsHandler} />
|
||||
) : (
|
||||
<Empty text='Connecting to server' />
|
||||
)}
|
||||
|
||||
@@ -33,14 +33,33 @@ export default function MenuBar(props) {
|
||||
|
||||
const handleUpload = (event) => {
|
||||
const fileUploaded = event.target.files[0];
|
||||
|
||||
if (fileUploaded == null) return;
|
||||
console.log(fileUploaded);
|
||||
|
||||
// Limit file size to 1MB
|
||||
if (fileUploaded.size > 1000000) {
|
||||
console.log('Error: File size limit (1MB) exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
if (fileUploaded.name.endsWith('.xlsx')) {
|
||||
console.log('excel file');
|
||||
} else if (fileUploaded.name.endsWith('.json')) {
|
||||
console.log('json file');
|
||||
} else {
|
||||
console.log('Error: File type unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploaddb.mutate(fileUploaded);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
// reset input value
|
||||
hiddenFileInput.current.value = '';
|
||||
};
|
||||
|
||||
const handleIPC = (action) => {
|
||||
@@ -102,7 +121,7 @@ export default function MenuBar(props) {
|
||||
style={{ display: 'none' }}
|
||||
ref={hiddenFileInput}
|
||||
onChange={handleUpload}
|
||||
accept='.json'
|
||||
accept='.json, .xlsx'
|
||||
/>
|
||||
<UploadIconBtn
|
||||
style={{ fontSize: '1.5em' }}
|
||||
|
||||
+1
-1
@@ -111,7 +111,7 @@
|
||||
"title": "All about Carlos demo event",
|
||||
"url": "www.carlosvalente.com",
|
||||
"publicInfo": "WiFi: demoproject \nPassword: ontimeproject",
|
||||
"backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject"
|
||||
"backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject",
|
||||
"endMessage": ""
|
||||
},
|
||||
"settings": {
|
||||
|
||||
+17
-7
@@ -26,22 +26,33 @@ import { Client } from 'node-osc';
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import cors from 'cors';
|
||||
import { dbModel } from './models/dataModel.js';
|
||||
import { dbModelv1 as dbModel } from './models/dataModel.js';
|
||||
import { parseJsonv1 as parseJson, validateFile } from './utils/parser.js';
|
||||
import ua from 'universal-analytics';
|
||||
|
||||
// Read data from JSON file, this will set db.data content
|
||||
await db.read();
|
||||
// validate JSON before attempting read
|
||||
let isValid = validateFile(file);
|
||||
|
||||
if (isValid) {
|
||||
console.log('reading this');
|
||||
// Read data from JSON file, this will set db.data content
|
||||
await db.read();
|
||||
}
|
||||
|
||||
// If file.json doesn't exist, db.data will be null
|
||||
// Set default data
|
||||
// db.data ||= { events: [] }; NODE v15 - v16
|
||||
if (db.data == null) {
|
||||
if (db.data == null || !isValid) {
|
||||
db.data = dbModel;
|
||||
db.write();
|
||||
await db.write();
|
||||
}
|
||||
|
||||
// get data
|
||||
export const data = db.data;
|
||||
// there is also the case of the db being corrupt
|
||||
// try to parse the data
|
||||
export const data = await parseJson(db.data);
|
||||
db.data = data;
|
||||
await db.write();
|
||||
|
||||
// Import Routes
|
||||
import { router as eventsRouter } from './routes/eventsRouter.js';
|
||||
@@ -107,7 +118,6 @@ app.use((err, req, res, next) => {
|
||||
*/
|
||||
|
||||
const s = data.settings;
|
||||
|
||||
const oscIP = s.oscOutIP || config.osc.ipOut;
|
||||
const oscOutPort = s.oscOutPort || config.osc.portOut;
|
||||
const oscInPort = s.oscInPort || config.osc.port;
|
||||
|
||||
@@ -2,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':
|
||||
|
||||
@@ -4,13 +4,8 @@ import { fileURLToPath } from 'url';
|
||||
|
||||
// get database
|
||||
import { db, data } from '../app.js';
|
||||
import {
|
||||
event as eventDef,
|
||||
delay as delayDef,
|
||||
block as blockDef,
|
||||
} from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { networkInterfaces } from 'os';
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -19,93 +14,6 @@ function getEventTitle() {
|
||||
return data.event.title;
|
||||
}
|
||||
|
||||
async function deleteFile(file) {
|
||||
// delete a file
|
||||
fs.unlink(file, (err) => {
|
||||
if (err) {
|
||||
console.log(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// parses version 1 of the data system
|
||||
async function parsev1(jsonData) {
|
||||
let numEntries = 0;
|
||||
if ('events' in jsonData) {
|
||||
console.log('Found events definition, importing...');
|
||||
let events = [];
|
||||
let ids = [];
|
||||
for (const e of jsonData.events) {
|
||||
if (e.type === 'event') {
|
||||
// doublecheck unique ids
|
||||
if (e.id == null || ids.indexOf(e.id) !== -1) continue;
|
||||
ids.push(e.id);
|
||||
|
||||
// make sure all properties exits
|
||||
// dont load any extra properties than the ones known
|
||||
events.push({
|
||||
...eventDef,
|
||||
title: e.title,
|
||||
subtitle: e.subtitle,
|
||||
presenter: e.presenter,
|
||||
note: e.note,
|
||||
timeStart: e.timeStart,
|
||||
timeEnd: e.timeEnd,
|
||||
isPublic: e.isPublic,
|
||||
id: e.id,
|
||||
});
|
||||
numEntries++;
|
||||
} else if (e.type === 'delay') {
|
||||
events.push({ ...delayDef, duration: e.duration });
|
||||
numEntries++;
|
||||
} else if (e.type === 'block') {
|
||||
events.push({ ...blockDef });
|
||||
numEntries++;
|
||||
}
|
||||
}
|
||||
// write to db
|
||||
db.data.events = events;
|
||||
db.write();
|
||||
console.log(`Uploaded file with ${numEntries} entries`);
|
||||
}
|
||||
|
||||
if ('event' in jsonData) {
|
||||
console.log('Found event data, importing...');
|
||||
const e = jsonData.event;
|
||||
// filter known properties
|
||||
const event = {
|
||||
...dbModel.event,
|
||||
title: e.title,
|
||||
url: e.url,
|
||||
publicInfo: e.publicInfo,
|
||||
backstageInfo: e.backstageInfo,
|
||||
endMessage: e.endMessage,
|
||||
};
|
||||
|
||||
// write to db
|
||||
db.data.event = event;
|
||||
db.write();
|
||||
}
|
||||
|
||||
// Settings handled partially
|
||||
if ('settings' in jsonData) {
|
||||
console.log('Found settings definition, importing...');
|
||||
const s = jsonData.settings;
|
||||
let settings = {};
|
||||
|
||||
if (s.oscInPort) settings.oscInPort = s.oscInPort;
|
||||
if (s.oscOutPort) settings.oscOutPort = s.oscOutPort;
|
||||
if (s.oscOutIP) settings.oscOutIP = s.oscOutIP;
|
||||
|
||||
// write to db
|
||||
db.data.settings = {
|
||||
...dbModel.settings,
|
||||
...settings,
|
||||
};
|
||||
db.write();
|
||||
}
|
||||
}
|
||||
|
||||
// Create controller for GET request to '/ontime/db'
|
||||
// Returns -
|
||||
export const dbDownload = async (req, res) => {
|
||||
@@ -121,6 +29,10 @@ export const dbDownload = async (req, res) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Controller for POST request to /ontime/db
|
||||
* @returns none
|
||||
*/
|
||||
const upload = async (file, req, res) => {
|
||||
if (!fs.existsSync(file)) {
|
||||
res.status(500).send({ message: 'Upload failed' });
|
||||
@@ -128,30 +40,30 @@ const upload = async (file, req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// get file
|
||||
const rawdata = fs.readFileSync(file);
|
||||
const uploadedJson = JSON.parse(rawdata);
|
||||
const result = await fileHandler(file);
|
||||
|
||||
// delete file
|
||||
deleteFile(file);
|
||||
|
||||
// check version
|
||||
if (uploadedJson.settings.version === 1) {
|
||||
try {
|
||||
parsev1(uploadedJson);
|
||||
global.timer.setupWithEventList(db.data.events);
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Error parsing file: ${error}` });
|
||||
if (result?.error) {
|
||||
res.status(400).send({ message: result.message });
|
||||
} else if (result.message === 'success') {
|
||||
if (result.data != null) {
|
||||
if (result.data?.events != null) {
|
||||
data.events = result.data.events;
|
||||
global.timer.setupWithEventList(result.data?.events);
|
||||
}
|
||||
if (result.data?.event != null) {
|
||||
data.event = result.data.event;
|
||||
}
|
||||
if (result.data?.settings != null) {
|
||||
data.settings = result.data.settings;
|
||||
}
|
||||
db.write();
|
||||
}
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
res.status(400).send({ message: 'Error parsing file, version unknown' });
|
||||
return;
|
||||
res.status(400).send({ message: 'Failed parsing, no data' });
|
||||
}
|
||||
|
||||
res.sendStatus(200);
|
||||
} catch (error) {
|
||||
console.log('Error parsing file', error);
|
||||
res.status(400).send({ message: error });
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const dbModel = {
|
||||
export const dbModelv1 = {
|
||||
events: [],
|
||||
event: {
|
||||
title: '',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import express from 'express';
|
||||
import uploadJson from '../utils/upload.js';
|
||||
import { uploadFile } from '../utils/upload.js';
|
||||
export const router = express.Router();
|
||||
|
||||
import {
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
router.get('/db', dbDownload);
|
||||
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.post('/db', uploadJson, dbUpload);
|
||||
router.post('/db', uploadFile, dbUpload);
|
||||
|
||||
// create route between controller and '/ontime/info' endpoint
|
||||
router.get('/info', uploadJson, getInfo);
|
||||
router.get('/info', uploadFile, getInfo);
|
||||
|
||||
// create route between controller and '/ontime/info' endpoint
|
||||
router.post('/info', postInfo);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { generateId } from '../generate_id.js';
|
||||
|
||||
describe('generate a valid id', () => {
|
||||
it('generates a 5 digit id', () => {
|
||||
const id = generateId();
|
||||
expect(id.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate 100', () => {
|
||||
it('all ids are unique', () => {
|
||||
let ids = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
ids.push(generateId());
|
||||
}
|
||||
|
||||
const unique = [...new Set(ids)];
|
||||
expect(ids.length).toBe(unique.length);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
const nanoid = customAlphabet('1234567890abcdef', 5);
|
||||
|
||||
export const generateId = () => nanoid();
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import multer from 'multer';
|
||||
import { statSync, mkdirSync } from 'fs';
|
||||
import { EXCEL_MIME, JSON_MIME } from './parser.js';
|
||||
|
||||
// Define multer storage object
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
let newDestination = 'uploads/';
|
||||
@@ -12,9 +14,7 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
if (stat && !stat.isDirectory()) {
|
||||
throw new Error(
|
||||
'Directory cannot be created because an inode of a different type exists at "' +
|
||||
dest +
|
||||
'"'
|
||||
`Directory cannot be created because an inode of a different type exists at ${newDestination}`
|
||||
);
|
||||
}
|
||||
cb(null, newDestination);
|
||||
@@ -24,15 +24,22 @@ const storage = multer.diskStorage({
|
||||
},
|
||||
});
|
||||
|
||||
// filter only json
|
||||
const filterJson = (req, file, cb) => {
|
||||
if (file.mimetype.includes('application/json')) {
|
||||
/**
|
||||
* @description Middleware function to filter allowed file types
|
||||
* @argument file - reference to file
|
||||
* @return {boolean} - file allowed
|
||||
*/
|
||||
const filterAllowed = (req, file, cb) => {
|
||||
if (file.mimetype.includes(JSON_MIME) || file.mimetype.includes(EXCEL_MIME)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
console.log('ERROR: Unrecognised file type');
|
||||
cb(null, false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadJson = multer({ storage: storage, fileFilter: filterJson });
|
||||
|
||||
export default uploadJson.single('jsondb');
|
||||
// Build multer uploader for a single file
|
||||
export const uploadFile = multer({
|
||||
storage: storage,
|
||||
fileFilter: filterAllowed,
|
||||
}).single('userFile');
|
||||
|
||||
+109
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user