Compare commits

...

28 Commits

Author SHA1 Message Date
cv 1b867b0dbc chore: update tests 2023-09-29 22:23:16 +02:00
cv 34b79e7699 chore: update tests 2023-09-29 22:21:15 +02:00
cv 4498a04a34 refactor: align import map to demo 2023-09-29 22:16:39 +02:00
cv d9df866308 refactor: enforce integers in numbers 2023-09-29 22:10:40 +02:00
cv 8146d88765 refactor: force refetch after mutation 2023-09-29 21:56:15 +02:00
cv d5cb2735ec Merge remote-tracking branch 'origin/master' into excel-import 2023-09-29 21:53:13 +02:00
cv 9b12afe2a6 refactor: align import map to demo 2023-09-29 21:40:22 +02:00
cv e10c0e8c97 refactor: typescript improvements 2023-09-29 21:39:50 +02:00
cv 4e9d9fc075 refactor: parse time fields 2023-09-29 21:12:40 +02:00
cv 45ece13d04 refactor: g-sheet time is object 2023-09-29 21:12:27 +02:00
cv e5e5798272 refactor: update type 2023-09-29 21:12:01 +02:00
cv cf7f6bdbf7 feat: resolve times on excel import, refs #508 2023-09-29 20:19:29 +02:00
cv a0d4f40bec refactor: small cleanups and type improvement 2023-09-28 08:15:54 +02:00
cv ba5ef6668d refactor: parse import fields 2023-09-28 08:15:24 +02:00
cv 9fc04f2fd1 chore: create feature endpoints 2023-09-27 15:09:48 +02:00
cv 13d72dd1a4 style: small presentation tweaks 2023-09-27 15:08:14 +02:00
cv 30905757f3 chore: increase size limits on uploads 2023-09-27 14:31:14 +02:00
cv e09a7d99f0 refactor: handle unexpected data types 2023-09-27 12:16:39 +02:00
cv 52ca04e063 chore: create import map utilities 2023-09-27 12:15:50 +02:00
cv b8188c7485 chore: add external deepmerge utility 2023-09-27 12:14:04 +02:00
cv 85172ae8d7 temp: handle inconsistent errors from server 2023-09-27 09:56:24 +02:00
cv cb871a8c26 refactor: simplify handling of notification 2023-09-27 09:55:18 +02:00
cv 88a853c158 refactor: convert to typescript 2023-09-17 20:55:23 +02:00
cv 99cd9bf0b7 refactor: simplify handling axios errors 2023-09-17 20:49:02 +02:00
cv 29fd145bd9 wip: create upload UI components 2023-09-17 20:48:27 +02:00
cv f6440a503f chore: upgrade deps 2023-09-17 13:16:46 +02:00
cv aa0d4103b1 style: tweaks on components 2023-09-16 14:09:49 +02:00
cv 12b051dadb refactor: rename utilities file 2023-09-16 14:09:17 +02:00
58 changed files with 1867 additions and 712 deletions
+30 -5
View File
@@ -2,18 +2,30 @@ import axios, { AxiosError } from 'axios';
import { LogLevel } from 'ontime-types'; import { LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils'; import { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time'; import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) { export function maybeAxiosError(error: unknown) {
let message;
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? ''; const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? ''; let data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`; if (typeof data === 'object') {
// TODO: use error instead, when migrated
if ('message' in data) {
data = JSON.stringify(data.message);
} else {
data = JSON.stringify(data);
}
}
return `${statusText}: ${data}`;
} else { } else {
message = `${prepend}: ${error}`; return error as string;
} }
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({ addLog({
id: generateId(), id: generateId(),
@@ -23,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) {
text: message, text: message,
}); });
} }
export async function invalidateAllCaches() {
await ontimeQueryClient.invalidateQueries([
'project',
'aliases',
'userFields',
'rundown',
'appinfo',
'oscSettings',
'appSettings',
'viewSettings',
]);
}
+68 -7
View File
@@ -1,5 +1,16 @@
import axios from 'axios'; import axios, { AxiosResponse } from 'axios';
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types'; import {
Alias,
DatabaseModel,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals'; import { apiRepoLatest } from '../../externals';
import { InfoType } from '../models/Info'; import { InfoType } from '../models/Info';
@@ -137,17 +148,26 @@ export const downloadRundown = async () => {
}); });
}; };
// TODO: should this be extracted to shared code?
export type ProjectFileImportOptions = {
onlyRundown: boolean;
};
/** /**
* @description HTTP request to upload events db * @description HTTP request to upload events db
* @return {Promise} * @return {Promise}
*/ */
type UploadDataOptions = { export const uploadProjectFile = async (
onlyRundown?: boolean; file: File,
}; setProgress: (value: number) => void,
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => { options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData(); const formData = new FormData();
formData.append('userFile', file); formData.append('userFile', file);
const onlyRundown = options?.onlyRundown || 'false';
const onlyRundown = Boolean(options?.onlyRundown);
console.log('debug here', onlyRundown, options);
await axios await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, { .post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: { headers: {
@@ -161,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
.then((response) => response.data.id); .then((response) => response.data.id);
}; };
/**
* @description Make patch changes to the objects in the db
* @return {Promise}
*/
export async function patchData(patchDb: Partial<DatabaseModel>) {
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
return response;
}
type PostPreviewExcelResponse = {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
};
/**
* @description Make patch changes to the objects in the db
* @return {Promise} - returns parsed rundown and userfields
*/
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
const formData = new FormData();
formData.append('userFile', file);
formData.append('options', JSON.stringify(options));
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
`${ontimeURL}/preview-spreadsheet`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
setProgress(complete);
},
},
);
return response;
}
export type HasUpdate = { export type HasUpdate = {
url: string; url: string;
version: string; version: string;
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft'; import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch'; import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps { interface ColourInputProps {
value: string; value: string;
name: TitleActions; name: EditorUpdateFields;
handleChange: (newValue: TitleActions, name: string) => void; handleChange: (newValue: EditorUpdateFields, name: string) => void;
} }
const colours = [ const colours = [
@@ -38,6 +38,12 @@
gap: $element-inner-spacing; gap: $element-inner-spacing;
} }
.noHover {
&:hover {
background-color: inherit;
}
}
.title { .title {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
display: block; display: block;
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react'; import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput'; import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { EditorUpdateFields } from '../EventEditor';
import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss'; import style from '../EventEditor.module.scss';
interface CountedTextAreaProps { interface CountedTextAreaProps {
field: TitleActions; field: EditorUpdateFields;
label: string; label: string;
initialValue: string; initialValue: string;
submitHandler: (field: TitleActions, value: string) => void; submitHandler: (field: EditorUpdateFields, value: string) => void;
} }
export default function CountedTextArea(props: CountedTextAreaProps) { export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title { .title {
font-size: $inner-section-text-size; font-size: $inner-section-text-size;
color: $gray-500; color: $gray-500;
padding-left: 8px; padding-left: 0.5rem;
margin: 8px 0; margin: 0.5rem 0;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red; color: $error-red;
} }
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection { .buttonSection {
margin-top: $section-spacing; margin-top: $section-spacing;
display: flex; display: flex;
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1; flex-grow: 1;
} }
.vSpacer {
height: 2rem;
}
.shiftRight { .shiftRight {
align-self: flex-end; align-self: flex-end;
} }
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom { .padBottom {
padding-bottom: $element-spacing; padding-bottom: $element-spacing;
} }
@@ -0,0 +1,67 @@
import { ChangeEvent, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import UploadEntry from './upload-entry/UploadEntry';
import { useUploadModalContextStore } from './uploadModalContext';
import { validateFile } from './uploadUtils';
import style from './UploadModal.module.scss';
export default function UploadFile() {
const fileInputRef = useRef<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string | undefined>();
const success = false;
const clearFile = () => {
setFile(null);
setErrors('');
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
setErrors('');
const selectedFile = event?.target?.files?.[0];
if (!selectedFile) {
setFile(null);
return;
}
try {
validateFile(selectedFile);
setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
}
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project or xlsx file
</div>
)}
{(file || errors) && (
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
)}
</>
);
}
@@ -5,79 +5,31 @@
.uploadBody { .uploadBody {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 1rem;
} }
.uploadArea { .uploadArea {
margin: 0 auto;
width: 100%; width: 100%;
min-height: 200px; max-width: 550px;
border: 2px dashed $gray-50;
min-height: 150px;
border: 2px dashed $gray-200;
border-radius: 3px; border-radius: 3px;
display: grid; display: grid;
place-content: center; place-content: center;
transition-property: background-color; transition-property: background-color;
transition-duration: $transition-time-action; transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover { &:hover {
border: 2px solid $blue-500; border: 2px solid $blue-500;
background-color: $blue-50; background-color: $blue-50;
cursor: pointer; cursor: pointer;
} }
&.comment { &.comment {
color: gray; color: $modal-note-color;
}
}
.uploadedItem {
background-color: $gray-50;
padding: 8px;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 16px;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 32px;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: 14px;
color: $gray-1350;
}
.fileInfo {
grid-area: info;
font-size: 12px;
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
} }
} }
@@ -89,3 +41,9 @@
.pad { .pad {
margin: 8px; margin: 8px;
} }
.twoColumn {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@@ -1,7 +1,6 @@
import { ChangeEvent, useCallback, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
Button, Button,
Input,
Modal, Modal,
ModalBody, ModalBody,
ModalCloseButton, ModalCloseButton,
@@ -9,22 +8,33 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
Progress,
Switch,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants'; import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import { uploadData } from '../../../common/api/ontimeApi'; import {
import { useEmitLog } from '../../../common/stores/logger'; patchData,
import ModalSplitInput from '../ModalSplitInput'; postPreviewExcel,
ProjectFileImportOptions,
uploadProjectFile,
} from '../../../common/api/ontimeApi';
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
import { validateFile } from './utils'; import PreviewExcel from './preview/PreviewExcel';
import ExcelFileOptions from './upload-options/ExcelFileOptions';
import OntimeFileOptions from './upload-options/OntimeFileOptions';
import UploadStepTracker from './upload-step/UploadStep';
import UploadFile from './UploadFile';
import { useUploadModalContextStore } from './uploadModalContext';
import { isExcelFile, isOntimeFile } from './uploadUtils';
import style from './UploadModal.module.scss'; import style from './UploadModal.module.scss';
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
export type UploadStep = 'upload' | 'review';
interface UploadModalProps { interface UploadModalProps {
onClose: () => void; onClose: () => void;
@@ -33,64 +43,130 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) { export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { emitError } = useEmitLog();
const [errors, setErrors] = useState<string | undefined>();
const [isSubmitting, setSubmitting] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [progress, setProgress] = useState(0);
const overrideOptionRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [success, setSuccess] = useState(false);
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => { const { file, setProgress, clear } = useUploadModalContextStore();
const fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const validate = validateFile(fileUploaded); const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
setErrors(validate.errors?.[0]); const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [userFields, setUserFields] = useState<UserFields | null>(null);
const [project, setProject] = useState<ProjectData | null>(null);
if (validate.isValid) { const [errors, setErrors] = useState('');
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const handleSubmit = useCallback(async () => { const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
setSubmitting(true); const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
/* if the modal re-opens, we want to restart all states */
useEffect(() => {
clear();
setUploadStep('upload');
setSubmitting(false);
setRundown(null);
setUserFields(null);
setProject(null);
setErrors('');
}, [clear, isOpen]);
/* uploads file to backend
* - in the case of excel, we get the preview
* - in the case of project file, this is end of line
**/
const handleUpload = async () => {
let doClose = false;
if (file) { if (file) {
setSubmitting(true);
setErrors('');
try { try {
const options = { if (isOntimeFile(file)) {
onlyRundown: overrideOptionRef.current?.checked || false, // TODO: we would also like to have preview for ontime project files
}; const options = ontimeFileOptions.current;
await uploadData(file, setProgress, options); await handleOntimeFile(file, options);
doClose = true;
} else if (isExcelFile(file)) {
const options = excelFileOptions.current;
await handleExcelFile(file, options);
await invalidateAllCaches();
}
} catch (error) { } catch (error) {
emitError(`Failed uploading file: ${error}`); const message = maybeAxiosError(error);
setErrors(`Failed uploading file ${message}`);
} finally { } finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE); setSubmitting(false);
setSuccess(true); if (doClose) {
handleClose();
}
} }
} }
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => { // when we upload excel, we populate state with preview data
fileInputRef.current?.click(); async function handleExcelFile(file: File, options: ExcelImportMap) {
}; const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
const clearFile = () => { setRundown(response.data.rundown);
setFile(null); setUserFields(response.data.userFields);
setProject(response.data.project);
// in excel imports we have an extra review step
setUploadStep('review');
}
}
// when we upload project files, no extra operations are done
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
await uploadProjectFile(file, setProgress, options);
}
}; };
// before closing the modal, we clear data from mutations
const handleClose = () => { const handleClose = () => {
clearFile(); clear();
setSuccess(false); setRundown([]);
setErrors(undefined); setUserFields(userFieldsPlaceholder);
setProgress(0); setProject(projectDataPlaceholder);
onClose(); onClose();
}; };
const disableSubmit = !file || isSubmitting; const handleFinalise = async () => {
// this step is currently only used for excel files, after preview
if (isExcel && rundown && userFields && project) {
let doClose = false;
setSubmitting(true);
try {
await patchData({ rundown, userFields, project });
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
queryClient.setQueryData(USERFIELDS, userFields);
queryClient.setQueryData(PROJECT_DATA, project);
await queryClient.invalidateQueries({
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
});
doClose = true;
} catch (error) {
const message = maybeAxiosError(error);
setErrors(`Failed applying changes ${message}`);
} finally {
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
};
const undoReview = () => {
setUploadStep('upload');
setErrors('');
};
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
const isExcel = isExcelFile(file);
const isOntime = isOntimeFile(file);
const handleGoBack = isUpload ? undefined : undoReview;
const handleSubmit = isUpload ? handleUpload : handleFinalise;
const disableSubmit = (isUpload && !file) || (isReview && rundown === null);
const disableGoBack = isUpload;
const submitText = isUpload ? 'Upload' : 'Finish';
return ( return (
<Modal <Modal
@@ -101,66 +177,51 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl' size='xl'
scrollBehavior='inside' scrollBehavior='inside'
preserveScrollBarGap preserveScrollBarGap
variant='ontime-small' variant='ontime-upload'
> >
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>File import</ModalHeader> <ModalHeader>File import</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody className={style.uploadBody}> <ModalBody className={style.uploadBody}>
<Input {isExcel && <UploadStepTracker uploadStep={uploadStep} />}
ref={fileInputRef} {uploadStep === 'upload' ? (
style={{ display: 'none' }} <>
type='file' <UploadFile />
onChange={handleFile} {isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
accept='.json, .xlsx' {isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
data-testid='file-input' </>
/> ) : (
<div className={style.uploadArea} onClick={handleClick}> <PreviewExcel
Click to upload Ontime project file rundown={rundown ?? []}
</div> project={project ?? projectDataPlaceholder}
{file && ( userFields={userFields ?? userFieldsPlaceholder}
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}> />
<IoClose className={style.cancelUpload} onClick={clearFile} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
)} )}
{errors && (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
<Progress className={style.fileProgress} value={progress} />
</div>
)}
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput
field=''
title='Only import rundown'
description='All other options, including application settings will be discarded'
>
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
</ModalSplitInput>
</div>
</ModalBody> </ModalBody>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}> <ModalFooter>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'> <div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
Cancel
</Button> <div className={`${style.buttonSection} ${style.pad}`}>
<Button <Button
onClick={handleSubmit} onClick={handleGoBack}
isLoading={isSubmitting} isDisabled={disableGoBack || submitting}
isDisabled={disableSubmit} variant='ontime-ghost-on-light'
variant='ontime-filled' size='sm'
padding='0 2em' >
size='sm' Go Back
> </Button>
Import <Button
</Button> onClick={handleSubmit}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
{submitText}
</Button>
</div>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -0,0 +1,22 @@
@use "../../../../theme/_ontimeColours" as *;
@mixin pad-item {
padding-left: 0.5rem;
padding-right: 1rem;
}
.previewTable {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: repeat(6, auto);
font-size: calc(1rem - 2px);
}
.field {
font-weight: 200;
@include pad-item;
}
.value {
@include pad-item;
}
@@ -0,0 +1,26 @@
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import PreviewProjectData from './PreviewProjectData';
import PreviewRundown from './PreviewRundown';
import style from '../../Modal.module.scss';
interface PreviewExcelProps {
rundown: OntimeRundown;
project: ProjectData;
userFields: UserFields;
}
export default function PreviewExcel(props: PreviewExcelProps) {
const { rundown, project, userFields } = props;
return (
<div className={`${style.column} ${style.noHover}`}>
<div className={style.title}>Review Project Data</div>
<PreviewProjectData project={project} />
<div className={style.vSpacer} />
<div className={style.title}>Review Rundown</div>
<PreviewRundown rundown={rundown} userFields={userFields} />
</div>
);
}
@@ -0,0 +1,26 @@
import { ProjectData } from 'ontime-types';
import style from './PreviewColumn.module.scss';
interface PreviewProjectDataProps {
project: ProjectData;
}
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
return (
<div className={style.previewTable}>
<span className={style.field}>Title</span>
<span className={style.value}>{project.title}</span>
<span className={style.field}>Description</span>
<span className={style.value}>{project.description}</span>
<span className={style.field}>Public URL</span>
<span className={style.value}>{project.publicUrl}</span>
<span className={style.field}>Public info</span>
<span className={style.value}>{project.publicInfo}</span>
<span className={style.field}>Backstage URL</span>
<span className={style.value}>{project.backstageUrl}</span>
<span className={style.field}>Backstage info</span>
<span className={style.value}>{project.backstageInfo}</span>
</div>
);
}
@@ -0,0 +1,125 @@
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import Tag from './Tag';
import style from './PreviewTable.module.scss';
interface PreviewRundownProps {
rundown: OntimeRundown;
userFields: UserFields;
}
function booleanToText(value?: boolean) {
return value ? 'Yes' : undefined;
}
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
return (
<div className={style.container}>
<div className={style.scrollContainer}>
<table className={style.rundownPreview}>
<thead className={style.header}>
<tr>
<th>#</th>
<th>Type</th>
<th>Cue</th>
<th>Title</th>
<th>Subtitle</th>
<th>Presenter</th>
<th>Note</th>
<th>Time Start</th>
<th>Time End</th>
<th>Duration</th>
<th>Is Public</th>
<th>Skip</th>
<th>Colour</th>
<th>Timer Type</th>
<th>End Action</th>
<th>
user0 <Tag>{userFields.user0}</Tag>
</th>
<th>
user1 <Tag>{userFields.user1}</Tag>
</th>
<th>
user2 <Tag>{userFields.user2}</Tag>
</th>
<th>
user3 <Tag>{userFields.user3}</Tag>
</th>
<th>
user4 <Tag>{userFields.user4}</Tag>
</th>
<th>
user5 <Tag>{userFields.user5}</Tag>
</th>
<th>
user6 <Tag>{userFields.user6}</Tag>
</th>
<th>
user7 <Tag>{userFields.user7}</Tag>
</th>
<th>
user8 <Tag>{userFields.user8}</Tag>
</th>
<th>
user9 <Tag>{userFields.user9}</Tag>
</th>
</tr>
</thead>
<tbody className={style.body}>
{rundown.map((event, index) => {
const key = event.id;
if (isOntimeEvent(event)) {
const colour = event.colour ? getAccessibleColour(event.colour) : {};
const isPublic = booleanToText(event.isPublic);
const skip = booleanToText(event.skip);
return (
<tr key={key}>
<td className={style.center}>
<Tag>{index + 1}</Tag>
</td>
<td className={style.center}>
<Tag>Event</Tag>
</td>
<td className={style.nowrap}>{event.cue}</td>
<td>{event.title}</td>
<td>{event.subtitle}</td>
<td>{event.presenter}</td>
<td>{event.note}</td>
<td>{millisToString(event.timeStart)}</td>
<td>{millisToString(event.timeEnd)}</td>
<td>{millisToString(event.duration)}</td>
<td>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td>
<td>
<Tag>{event.timerType}</Tag>
</td>
<td>
<Tag>{event.endAction}</Tag>
</td>
<td>{event.user0}</td>
<td>{event.user1}</td>
<td>{event.user2}</td>
<td>{event.user3}</td>
<td>{event.user4}</td>
<td>{event.user5}</td>
<td>{event.user6}</td>
<td>{event.user7}</td>
<td>{event.user8}</td>
<td>{event.user9}</td>
</tr>
);
}
return null;
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,60 @@
@use "../../../../theme/_ontimeColours" as *;
.container {
max-width: 100%;
max-height: max(300px, 30vh);
overflow: scroll;
}
.scrollContainer {
overflow-x: scroll;
}
.rundownPreview {
font-size: calc(1rem - 2px);
border-collapse: separate;
}
.header,
.body {
th {
font-weight: 400;
height: unset;
line-height: calc(1rem - 2px);
white-space: nowrap;
padding-left: 0.25rem;
padding-right: 1rem;
}
}
.header {
th {
font-weight: 200;
text-align: left;
}
tr {
word-wrap: unset;
}
}
.body {
tr:nth-child(odd) {
background-color: $gray-50;
}
td {
text-align: left;
vertical-align: top;
padding: 0 0.5em;
}
.center {
text-align: center;
}
.nowrap {
white-space: nowrap;
}
}
@@ -0,0 +1,10 @@
@use "../../../../theme/_ontimeColours" as *;
.tag {
font-size: 10px;
background-color: $blue-500;
color: $pure-white;
border-radius: 2px;
padding: 0 0.25rem;
white-space: nowrap;
}
@@ -0,0 +1,7 @@
import { ReactNode } from 'react';
import style from './Tag.module.scss';
export default function Tag({ children }: { children: ReactNode }) {
return <span className={style.tag}>{children}</span>;
}
@@ -0,0 +1,59 @@
@use '../../../../theme/ontimeColours' as *;
@use '../../../../theme/v2Styles' as *;
.uploadedItem {
margin: 0 auto;
width: 100%;
max-width: 550px;
border: 1px solid $gray-200;
padding: 0.5rem;
display: grid;
grid-template-areas:
"icon title close"
"icon info ."
"progress progress progress";
grid-template-columns: auto 1fr auto;
column-gap: 1rem;
border-radius: 3px;
.icon {
align-self: center;
grid-area: icon;
font-size: 2rem;
color: $gray-700;
}
.fileTitle {
grid-area: title;
font-size: calc(1rem - 2px);
color: $ui-black;
}
.fileInfo {
grid-area: info;
font-size: calc(1rem - 4px);
color: $gray-1100;
}
.fileProgress {
grid-area: progress;
}
.cancelUpload {
grid-area: close;
cursor: pointer;
}
&.error {
.icon {
color: $error-red;
}
}
&.success {
.icon {
color: $green-500;
}
}
}
@@ -0,0 +1,53 @@
import { Progress } from '@chakra-ui/react';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { isExcelFile, isOntimeFile } from '../uploadUtils';
import style from './UploadEntry.module.scss';
interface UploadEntryProps {
file: File | null;
errors?: string;
progress: number;
success: boolean;
handleClear: () => void;
}
export default function UploadEntry(props: UploadEntryProps) {
const { file, errors, progress, success, handleClear } = props;
if (errors) {
return (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
} else if (isExcelFile(file)) {
fileType = 'Excel Rundown';
}
return (
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -0,0 +1,76 @@
import { MutableRefObject } from 'react';
import { ExcelImportMap } from 'ontime-utils';
import ImportMapTable, { type TableEntry } from './ImportMapTable';
import style from '../UploadModal.module.scss';
interface ExcelFileOptionsProps {
optionsRef: MutableRefObject<ExcelImportMap>;
}
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
// avoid unnecessary changes
if (optionsRef.current[field] !== value) {
optionsRef.current = { ...optionsRef.current, [field]: value };
}
};
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
const timings: TableEntry[] = [
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
];
const titles: TableEntry[] = [
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
{ label: 'Title', title: 'title', value: optionsRef.current.title },
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
{ label: 'Note', title: 'note', value: optionsRef.current.note },
];
const options: TableEntry[] = [
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
{ label: 'Skip', title: 'skip', value: optionsRef.current.skip },
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
];
const userFields: TableEntry[] = [
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
];
return (
<div className={style.uploadOptions}>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
</div>
<div className={style.twoEqualColumn}>
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
</div>
</div>
);
}
@@ -0,0 +1,33 @@
@use '../../../../theme/v2Styles' as *;
@use '../../../../theme/ontimeColours' as *;
.importTable {
margin: 0.5rem;
height: fit-content;
thead {
color: $gray-500;
text-transform: uppercase;
width: 10em;
}
tr:hover {
background-color: $gray-50;
}
tbody {
td {
max-width: fit-content;
}
}
}
.label {
display: inline-block;
min-width: 6em;
font-size: $inner-section-text-size;
}
.input {
width: 100%;
}
@@ -0,0 +1,50 @@
import { Input } from '@chakra-ui/react';
import { ExcelImportMap } from 'ontime-utils';
import style from './ImportMapTable.module.scss';
export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string };
interface ImportMapTableProps {
title: string;
fields: TableEntry[];
handleOnChange: (field: keyof ExcelImportMap, value: string) => void;
}
export default function ImportMapTable(props: ImportMapTableProps) {
const { title, fields, handleOnChange } = props;
return (
<table className={style.importTable}>
<thead>
<tr>
<td colSpan={2}>{title}</td>
</tr>
</thead>
<tbody>
{fields.map((field) => {
return (
<tr key={field.title}>
<td className={style.label}>
<label htmlFor={field.title}>{field.title}</label>
</td>
<td className={style.input}>
<Input
id={field.title}
size='xs'
variant='ontime-filled-on-light'
maxLength={25}
defaultValue={field.value}
placeholder='Use default column name'
onBlur={(event) => {
handleOnChange(field.title, event.target.value);
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
@@ -0,0 +1,34 @@
import { MutableRefObject } from 'react';
import { Switch } from '@chakra-ui/react';
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
import ModalSplitInput from '../../ModalSplitInput';
import style from '../UploadModal.module.scss';
interface OntimeFileOptionsProps {
optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef } = props;
const updateRef = <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => {
optionsRef.current = { ...optionsRef.current, [field]: value };
};
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateRef('onlyRundown', e.target.checked);
}}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/>
</ModalSplitInput>
</div>
);
}
@@ -0,0 +1,40 @@
@use '../../../../theme/ontimeColours' as *;
@mixin row {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.25rem;
}
.stepRow {
display: flex;
gap: 2rem;
align-items: center;
margin: 0 auto;
font-size: 1rem;
}
.idle {
@include row;
color: $blue-500;
}
.inactive {
@include row;
color: $gray-700;
}
.active {
@include row;
color: $blue-700;
}
.inactiveIcon {
color: $gray-700;
}
.activeIcon {
color: $blue-500;
}
@@ -0,0 +1,26 @@
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
import type { UploadStep } from '../UploadModal';
import style from './UploadStep.module.scss';
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
const isUpload = uploadStep === 'upload';
const isReview = uploadStep === 'review';
return (
<div className={style.stepRow}>
<div className={isUpload ? style.active : style.idle}>
<IoCheckmarkCircle />
Upload
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { create } from 'zustand';
type UploadModalContext = {
file: File | null;
setFile: (file: File | null) => void;
progress: number;
setProgress: (progress: number) => void;
clear: () => void;
};
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
@@ -0,0 +1,28 @@
export function validateFile(file: File) {
if (!file) {
throw new Error('No file to upload');
}
// Limit file size of a project file to around 1MB
if (file.name.endsWith('.json') && file.size > 1_000_000) {
throw new Error('File size limit (1MB) exceeded');
}
// Limit file size of an excel file to around 10MB
if (file.name.endsWith('.xlsx') && file.size > 10_000_000) {
throw new Error('File size limit (10MB) exceeded');
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
throw new Error('Unhandled file type');
}
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
export function isOntimeFile(file: File | null) {
return file?.name.endsWith('.json');
}
@@ -1,25 +0,0 @@
type ValidationStatus = {
errors: string[];
isValid: boolean;
};
export function validateFile(file: File): ValidationStatus {
const status: ValidationStatus = { errors: [], isValid: true };
if (!file) {
status.errors.push('No file to upload');
status.isValid = false;
}
// Limit file size to 1MB
if (file.size > 1000000) {
status.errors.push('File size limit (1MB) exceeded');
status.isValid = false;
}
// Check file extension
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
status.errors.push('Unhandled file type');
status.isValid = false;
}
return status;
}
+8
View File
@@ -0,0 +1,8 @@
export const ontimeProgressGray = {
track: {
background: '#f6f6f6', // $gray-500
},
filledTrack: {
background: '#578AF4', // $blue-500
},
};
+17 -5
View File
@@ -2,8 +2,8 @@ export const ontimeModal = {
header: { header: {
fontWeight: 400, fontWeight: 400,
letterSpacing: '0.3px', letterSpacing: '0.3px',
padding: '16px 24px', padding: '1rem 1.5rem',
fontSize: '20px', fontSize: '1.25rem',
color: '#202020', // $gray-50 color: '#202020', // $gray-50
}, },
dialog: { dialog: {
@@ -20,17 +20,29 @@ export const ontimeModal = {
color: '#202020', // $gray-50 color: '#202020', // $gray-50
}, },
footer: { footer: {
padding: '8px', padding: '0.5rem',
}, },
}; };
export const ontimeSmallModal = { export const ontimeSmallModal = {
...ontimeModal, ...ontimeModal,
body: { body: {
padding: '16px', padding: '1rem',
fontSize: '14px', fontSize: 'calc(1rem - 2px)',
}, },
dialog: { dialog: {
minHeight: 'min(200px, 10vh)', minHeight: 'min(200px, 10vh)',
}, },
}; };
export const ontimeUploadModal = {
...ontimeSmallModal,
body: {
padding: '1rem',
fontSize: 'calc(1rem - 2px)',
},
dialog: {
minHeight: 'min(200px, 10vh)',
maxWidth: 'min(800px, 80vh)',
},
};
+8 -1
View File
@@ -13,7 +13,8 @@ import {
import { ontimeCheckboxOnDark } from './ontimeCheckbox'; import { ontimeCheckboxOnDark } from './ontimeCheckbox';
import { ontimeEditable } from './ontimeEditable'; import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu'; import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal, ontimeSmallModal } from './ontimeModal'; import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal';
import { ontimeProgressGray } from './OntimeProgress';
import { ontimeBlockRadio } from './ontimeRadio'; import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect'; import { ontimeSelect } from './ontimeSelect';
import { lightSwitch, ontimeSwitch } from './ontimeSwitch'; import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
@@ -79,6 +80,12 @@ const theme = extendTheme({
variants: { variants: {
ontime: { ...ontimeModal }, ontime: { ...ontimeModal },
'ontime-small': { ...ontimeSmallModal }, 'ontime-small': { ...ontimeSmallModal },
'ontime-upload': { ...ontimeUploadModal },
},
},
Progress: {
variants: {
'ontime-on-light': { ...ontimeProgressGray },
}, },
}, },
Radio: { Radio: {
+1 -1
View File
@@ -15,7 +15,7 @@
"lowdb": "^5.0.5", "lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-osc": "^8.0.10", "node-osc": "^8.0.10",
"node-xlsx": "^0.21.0", "node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"passport": "^0.6.0", "passport": "^0.6.0",
"passport-local": "~1.0.0", "passport-local": "~1.0.0",
@@ -2,7 +2,16 @@
* Class Event Provider is a mediator for handling the local db * Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data * and adds logic specific to ontime data
*/ */
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types'; import {
ProjectData,
OntimeRundown,
ViewSettings,
DatabaseModel,
OSCSettings,
UserFields,
Alias,
Settings,
} from 'ontime-types';
import { data, db } from '../../modules/loadDb.js'; import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js'; import { safeMerge } from './DataProvider.utils.js';
@@ -45,7 +54,7 @@ export class DataProvider {
return data.settings; return data.settings;
} }
static async setSettings(newData) { static async setSettings(newData: Settings) {
data.settings = { ...newData }; data.settings = { ...newData };
await this.persist(); await this.persist();
} }
@@ -58,7 +67,7 @@ export class DataProvider {
return data.aliases; return data.aliases;
} }
static async setAliases(newData) { static async setAliases(newData: Alias[]) {
data.aliases = newData; data.aliases = newData;
await this.persist(); await this.persist();
} }
@@ -76,12 +85,12 @@ export class DataProvider {
await this.persist(); await this.persist();
} }
static async setUserFields(newData) { static async setUserFields(newData: UserFields) {
data.userFields = { ...newData }; data.userFields = { ...newData };
await this.persist(); await this.persist();
} }
static async setOsc(newData) { static async setOsc(newData: OSCSettings) {
data.osc = { ...newData }; data.osc = { ...newData };
await this.persist(); await this.persist();
} }
@@ -95,7 +104,7 @@ export class DataProvider {
await db.write(); await db.write();
} }
static async mergeIntoData(newData) { static async mergeIntoData(newData: Partial<DatabaseModel>) {
const mergedData = safeMerge(data, newData); const mergedData = safeMerge(data, newData);
data.project = mergedData.project; data.project = mergedData.project;
data.settings = mergedData.settings; data.settings = mergedData.settings;
@@ -1,10 +1,12 @@
import { DatabaseModel } from 'ontime-types';
/** /**
* Merges two data objects * Merges two data objects
* @param {object} existing * @param {object} existing
* @param {object} newData * @param {object} newData
*/ */
export function safeMerge(existing, newData) { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {}; const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
return { return {
...existing, ...existing,
rundown: rundown ?? existing.rundown, rundown: rundown ?? existing.rundown,
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
: {}), : {}),
}, },
}, },
http: { ...existing.http, ...http },
}; };
} }
@@ -42,11 +42,6 @@ describe('safeMerge', () => {
onFinish: [], onFinish: [],
}, },
}, },
http: {
enabled: true,
user: null,
pwd: null,
},
}; };
it('returns existing data if new data is not provided', () => { it('returns existing data if new data is not provided', () => {
@@ -188,19 +183,6 @@ describe('safeMerge', () => {
onFinish: [], onFinish: [],
}, },
}, },
http: {
user: null,
pwd: null,
messages: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
enabled: true,
},
}; };
const newData = { const newData = {
+96 -36
View File
@@ -1,4 +1,4 @@
import { Alias, LogOrigin, ProjectData } from 'ontime-types'; import { Alias, DatabaseModel, LogOrigin, ProjectData } from 'ontime-types';
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import fs from 'fs'; import fs from 'fs';
@@ -7,13 +7,15 @@ import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js'; import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/PlaybackService.js'; import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js'; import { eventStore } from '../stores/EventStore.js';
import { isDocker, resolveDbPath } from '../setup.js'; import { isDocker, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { logger } from '../classes/Logger.js'; import { logger } from '../classes/Logger.js';
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js'; import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
import { deepmerge } from 'ontime-utils';
import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
// Create controller for GET request to '/ontime/poll' // Create controller for GET request to '/ontime/poll'
// Returns data for current state // Returns data for current state
@@ -43,44 +45,43 @@ export const dbDownload = async (req, res) => {
}); });
}; };
// TODO: docs
// TODO: cleanup usage
/** /**
* handles file upload * Parses a file and returns the result objects
* @param file
* @param _req
* @param _res
* @param options
*/
async function parseFile(file, _req, _res, options) {
if (!fs.existsSync(file)) {
throw new Error('Upload failed');
}
const result = await fileHandler(file, options);
return result.data;
}
/**
* parse an uploaded file and apply its parsed objects
* @param file * @param file
* @param req * @param req
* @param res * @param res
* @param [options] * @param [options]
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const uploadAndParse = async (file, req, res, options) => { const parseAndApply = async (file, _req, res, options) => {
if (!fs.existsSync(file)) { const result = await parseFile(file, _req, res, options);
res.status(500).send({ message: 'Upload failed' });
return;
}
try { PlaybackService.stop();
const result = await fileHandler(file);
if ('error' in result && result.error) { const newRundown = result.rundown || [];
res.status(400).send({ message: result.message }); if (options?.onlyRundown === 'true') {
} else if ('data' in result && result.message === 'success') { await DataProvider.setRundown(newRundown);
PlaybackService.stop(); } else {
// explicitly write objects await DataProvider.mergeIntoData(result);
if (typeof result !== 'undefined') {
const newRundown = result.data.rundown || [];
if (options?.onlyRundown === 'true') {
await DataProvider.setRundown(newRundown);
} else {
await DataProvider.mergeIntoData(result.data);
}
}
forceReset();
res.sendStatus(200);
} else {
res.status(400).send({ message: 'Failed parsing, no data' });
}
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
} }
notifyChanges({ timer: true, external: true, reset: true });
}; };
/** /**
@@ -169,7 +170,7 @@ export const postUserFields = async (req, res) => {
} }
try { try {
const persistedData = DataProvider.getUserFields(); const persistedData = DataProvider.getUserFields();
const newData = mergeObject(persistedData, req.body); const newData = deepmerge(persistedData, req.body);
await DataProvider.setUserFields(newData); await DataProvider.setUserFields(newData);
res.status(200).send(newData); res.status(200).send(newData);
} catch (error) { } catch (error) {
@@ -319,8 +320,38 @@ export const postOSC = async (req, res) => {
} }
}; };
// Create controller for POST request to '/ontime/db' export async function patchPartialProjectFile(req, res) {
// Returns - if (failEmptyObjects(req.body, res)) {
return;
}
try {
const patchDb: Partial<DatabaseModel> = {
project: req.body?.project,
settings: req.body?.settings,
viewSettings: req.body?.viewSettings,
osc: req.body?.osc,
aliases: req.body?.aliases,
userFields: req.body?.userFields,
rundown: req.body?.rundown,
};
await DataProvider.mergeIntoData(patchDb);
if (patchDb.rundown !== undefined) {
// it is likely cheaper to invalidate cache than to calculate diff
PlaybackService.stop();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
notifyChanges({ external: true, reset: true });
}
res.status(200).send();
} catch (error) {
res.status(400).send(error);
}
}
/**
* uploads and parses a given file
*/
export const dbUpload = async (req, res) => { export const dbUpload = async (req, res) => {
if (!req.file) { if (!req.file) {
res.status(400).send({ message: 'File not found' }); res.status(400).send({ message: 'File not found' });
@@ -328,10 +359,39 @@ export const dbUpload = async (req, res) => {
} }
const options = req.query; const options = req.query;
const file = req.file.path; const file = req.file.path;
await uploadAndParse(file, req, res, options); try {
await parseAndApply(file, req, res, options);
res.status(200).send();
} catch (error) {
res.status(400).send({ message: `Failed parsing ${error}` });
}
}; };
// Create controller for POST request to '/ontime/new' /**
* uploads and parses an excel file
* @returns parsed result
*/
export async function previewExcel(req, res) {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
return;
}
try {
const options = JSON.parse(req.body.options);
const file = req.file.path;
const data = await parseFile(file, req, res, options);
res.status(200).send(data);
} catch (error) {
res.status(500).send({ message: error.toString() });
}
}
/**
* Meant to create a new project file, it will clear only fields which are specific to a project
* @param req
* @param res
*/
export const postNew: RequestHandler = async (req, res) => { export const postNew: RequestHandler = async (req, res) => {
try { try {
const newProjectData: ProjectData = { const newProjectData: ProjectData = {
@@ -118,3 +118,18 @@ export const validateOscSubscription = [
next(); next();
}, },
]; ];
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
body('aliases').isArray().optional({ nullable: false }),
body('userFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
+1 -1
View File
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
adapterToUse.data = dbModel; adapterToUse.data = dbModel;
} }
return parseJson(adapterToUse.data, true); return parseJson(adapterToUse.data);
}; };
/** /**
+9
View File
@@ -9,6 +9,7 @@ import {
getSettings, getSettings,
getUserFields, getUserFields,
getViewSettings, getViewSettings,
patchPartialProjectFile,
poll, poll,
postAliases, postAliases,
postNew, postNew,
@@ -17,12 +18,14 @@ import {
postSettings, postSettings,
postUserFields, postUserFields,
postViewSettings, postViewSettings,
previewExcel,
} from '../controllers/ontimeController.js'; } from '../controllers/ontimeController.js';
import { import {
validateAliases, validateAliases,
validateOSC, validateOSC,
validateOscSubscription, validateOscSubscription,
validatePatchProjectFile,
validateSettings, validateSettings,
validateUserFields, validateUserFields,
viewValidator, viewValidator,
@@ -40,6 +43,12 @@ router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint // create route between controller and '/ontime/db' endpoint
router.post('/db', uploadFile, dbUpload); router.post('/db', uploadFile, dbUpload);
// create route between controller and '/ontime/excel' endpoint
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
// create route between controller and '/ontime/preview-spreadsheet' endpoint
router.post('/preview-spreadsheet', uploadFile, previewExcel);
// create route between controller and '/ontime/settings' endpoint // create route between controller and '/ontime/settings' endpoint
router.get('/settings', getSettings); router.get('/settings', getSettings);
@@ -35,7 +35,6 @@ import { clock } from '../Clock.js';
*/ */
export function forceReset() { export function forceReset() {
eventLoader.reset(); eventLoader.reset();
sendRefetch();
runtimeCacheStore.invalidate(delayedRundownCacheKey); runtimeCacheStore.invalidate(delayedRundownCacheKey);
} }
@@ -192,15 +191,11 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
// modify rundown // modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock); await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
// notify timer service of changed events notifyChanges({ timer: [id], external: true });
updateTimer([id]);
// notify event loader that rundown size has changed // notify event loader that rundown size has changed
updateChangeNumEvents(); updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
return newEvent; return newEvent;
} }
@@ -211,11 +206,7 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
const newEvent = await cachedEdit(eventData.id, eventData); const newEvent = await cachedEdit(eventData.id, eventData);
// notify timer service of changed events notifyChanges({ timer: [newEvent.id], external: true });
updateTimer([newEvent.id]);
// advice socket subscribers of change
sendRefetch();
return newEvent; return newEvent;
} }
@@ -228,14 +219,9 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
export async function deleteEvent(eventId) { export async function deleteEvent(eventId) {
await cachedDelete(eventId); await cachedDelete(eventId);
// notify timer service of changed events notifyChanges({ timer: [eventId], external: true });
updateTimer([eventId]);
// notify event loader that rundown size has changed // notify event loader that rundown size has changed
updateChangeNumEvents(); updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
} }
/** /**
@@ -245,9 +231,7 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() { export async function deleteAllEvents() {
await cachedClear(); await cachedClear();
// notify timer service of changed events notifyChanges({ timer: true, external: true, reset: true });
updateTimer();
forceReset();
} }
/** /**
@@ -260,22 +244,15 @@ export async function deleteAllEvents() {
export async function reorderEvent(eventId: string, from: number, to: number) { export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to); const reorderedItem = await cachedReorder(eventId, from, to);
// notify timer service of changed events notifyChanges({ timer: true, external: true });
updateTimer();
// advice socket subscribers of change
sendRefetch();
return reorderedItem; return reorderedItem;
} }
export async function applyDelay(eventId: string) { export async function applyDelay(eventId: string) {
await cachedApplyDelay(eventId); await cachedApplyDelay(eventId);
// notify timer service of changed events notifyChanges({ timer: true, external: true });
updateTimer();
// advice socket subscribers of change
sendRefetch();
} }
/** /**
@@ -287,11 +264,7 @@ export async function applyDelay(eventId: string) {
export async function swapEvents(from: string, to: string) { export async function swapEvents(from: string, to: string) {
await cachedSwap(from, to); await cachedSwap(from, to);
// notify timer service of changed events notifyChanges({ timer: true, external: true });
updateTimer();
// advice socket subscribers of change
sendRefetch();
} }
/** /**
@@ -301,3 +274,26 @@ export async function swapEvents(from: string, to: string) {
function updateChangeNumEvents() { function updateChangeNumEvents() {
eventLoader.updateNumEvents(); eventLoader.updateNumEvents();
} }
/**
* Notify services of changes in the rundown
*/
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
if (options.timer) {
// notify timer service of changed events
if (Array.isArray(options.timer)) {
updateTimer(options.timer);
}
updateTimer();
}
if (options.reset) {
// force rundown to be recalculated
forceReset();
}
if (options.external) {
// advice socket subscribers of change
sendRefetch();
}
}
+44 -31
View File
@@ -1,6 +1,6 @@
import { vi } from 'vitest'; import { vi } from 'vitest';
import { EndAction, TimerType } from 'ontime-types'; import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js'; import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js'; import { parseExcel, parseJson, validateEvent } from '../parser.js';
@@ -525,7 +525,7 @@ describe('test event validator', () => {
expect(typeof validated.timeStart).toEqual('number'); expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0); expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number'); expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(0); expect(validated.timeEnd).toEqual(2);
}); });
it('handles bad objects', () => { it('handles bad objects', () => {
@@ -579,24 +579,24 @@ describe('test parseExcel function', () => {
[ [
'Time Start', 'Time Start',
'Time End', 'Time End',
'Event Title', 'Title',
'Presenter Name', 'Presenter',
'Event Subtitle', 'Subtitle',
'End Action', 'End Action',
'Timer type', 'Timer type',
'Is Public? (x)', 'Public',
'Skip? (x)', 'Skip',
'Notes', 'Notes',
'User0:test0', 'test0',
'User1:test1', 'test1',
'User2:test2', 'test2',
'User3:test3', 'test3',
'User4:test4', 'test4',
'User5:test5', 'test5',
'User6:test6', 'test6',
'user7:test7', 'test7',
'user8:test8', 'test8',
'user9:test9', 'test9',
'Colour', 'Colour',
'cue', 'cue',
], ],
@@ -651,6 +651,19 @@ describe('test parseExcel function', () => {
[], [],
]; ];
const partialOptions = {
user0: 'test0',
user1: 'test1',
user2: 'test2',
user3: 'test3',
user4: 'test4',
user5: 'test5',
user6: 'test6',
user7: 'test7',
user8: 'test8',
user9: 'test9',
};
const expectedParsedProjectData = { const expectedParsedProjectData = {
title: 'Test Event', title: 'Test Event',
description: 'test description', description: 'test description',
@@ -706,7 +719,7 @@ describe('test parseExcel function', () => {
}, },
]; ];
const parsedData = await parseExcel(testdata); const parsedData = parseExcel(testdata, partialOptions);
expect(parsedData.project).toStrictEqual(expectedParsedProjectData); expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
expect(parsedData.rundown).toBeDefined(); expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]); expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
@@ -835,7 +848,16 @@ describe('test views import', () => {
app: 'ontime', app: 'ontime',
version: 2, version: 2,
}, },
viewSettings: {}, viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
notAthing: true,
},
views: { views: {
overrideStyles: true, overrideStyles: true,
}, },
@@ -849,7 +871,7 @@ describe('test views import', () => {
endMessage: '', endMessage: '',
overrideStyles: false, overrideStyles: false,
}; };
const parsed = parseViewSettings(testData, false); const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(expectedParsedViewSettings); expect(parsed).toStrictEqual(expectedParsedViewSettings);
}); });
@@ -861,16 +883,7 @@ describe('test views import', () => {
version: 2, version: 2,
}, },
}; };
const expectedParsedViewSettings = { const parsed = parseViewSettings(testData);
normalColor: '#ffffffcc', expect(parsed).toStrictEqual({});
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData, true);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
}); });
}); });
@@ -45,7 +45,7 @@ describe('mergeObject()', () => {
third: '', third: '',
}); });
}); });
test.skip('it only merges fields of the first object', () => { test('it only merges fields of the first object', () => {
const a = { const a = {
first: 'yes', first: 'yes',
second: 'yes', second: 'yes',
@@ -64,6 +64,35 @@ describe('mergeObject()', () => {
third: '', third: '',
}); });
}); });
test('merges nested objects', () => {
// Define a sample object with nested properties
const a = {
name: 'John',
address: {
city: 'New York',
postalCode: '10001',
},
};
// Define a partial object with nested properties for merging
const b = {
name: 'Doe',
address: {
city: 'San Francisco',
state: 'CA',
},
};
const merged = mergeObject(a, b);
expect(merged.name).toBe('Doe');
expect(merged.address.city).toBe('San Francisco');
// @ts-expect-error -- its ok, just checking
expect(merged.address.state).toBe('CA');
expect(merged.address.postalCode).toBe('10001');
expect(merged.address).not.toBe(a.address);
expect(merged.address).not.toBe(b.address);
});
}); });
describe('removeUndefined()', () => { describe('removeUndefined()', () => {
+178 -179
View File
@@ -1,18 +1,27 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment import {
// @ts-nocheck -- not ready to fully type generateId,
isExcelImportMap,
import fs from 'fs'; type ExcelImportMap,
import xlsx from 'node-xlsx'; defaultExcelImportMap,
import { generateId, calculateDuration } from 'ontime-utils'; validateEndAction,
validateTimerType,
type ExcelImportOptions,
validateTimes,
} from 'ontime-utils';
import { import {
DatabaseModel, DatabaseModel,
EndAction,
OntimeEvent, OntimeEvent,
OntimeRundown, OntimeRundown,
SupportedEvent, SupportedEvent,
TimerType, ProjectData,
UserFields, UserFields,
EndAction,
TimerType,
} from 'ontime-types'; } from 'ontime-types';
import fs from 'fs';
import xlsx from 'node-xlsx';
import { event as eventDef } from '../models/eventsDefinition.js'; import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js'; import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js'; import { deleteFile, makeString } from './parserUtils.js';
@@ -33,27 +42,55 @@ export const JSON_MIME = 'application/json';
/** /**
* @description Excel array parser * @description Excel array parser
* @param {array} excelData - array with excel sheet * @param {array} excelData - array with excel sheet
* @param {ExcelImportOptions} options - an object that contains the import map
* @returns {object} - parsed object * @returns {object} - parsed object
*/ */
export const parseExcel = async (excelData) => { export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
const projectData: Partial<ProjectData> = { const projectData: Partial<ProjectData> = {
title: '', title: '',
description: '', description: '',
publicUrl: '', publicUrl: '',
publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '',
};
const customUserFields: Partial<UserFields> = {
user0: importMap.user0,
user1: importMap.user1,
user2: importMap.user2,
user3: importMap.user3,
user4: importMap.user4,
user5: importMap.user5,
user6: importMap.user6,
user7: importMap.user7,
user8: importMap.user8,
user9: importMap.user9,
}; };
const customUserFields: Partial<UserFields> = {};
const rundown: OntimeRundown = []; const rundown: OntimeRundown = [];
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null; // title stuff: strings
let titleIndex: number | null = null; let titleIndex: number | null = null;
let cueIndex: number | null = null; let cueIndex: number | null = null;
let presenterIndex: number | null = null; let presenterIndex: number | null = null;
let subtitleIndex: number | null = null; let subtitleIndex: number | null = null;
let isPublicIndex: number | null = null;
let skipIndex: number | null = null;
let notesIndex: number | null = null; let notesIndex: number | null = null;
let colourIndex: number | null = null; let colourIndex: number | null = null;
// options: booleans
let isPublicIndex: number | null = null;
let skipIndex: number | null = null;
// times: numbers
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let durationIndex: number | null = null;
// options: enum properties
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
// user fields: strings
let user0Index: number | null = null; let user0Index: number | null = null;
let user1Index: number | null = null; let user1Index: number | null = null;
let user2Index: number | null = null; let user2Index: number | null = null;
@@ -64,13 +101,11 @@ export const parseExcel = async (excelData) => {
let user7Index: number | null = null; let user7Index: number | null = null;
let user8Index: number | null = null; let user8Index: number | null = null;
let user9Index: number | null = null; let user9Index: number | null = null;
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
excelData excelData
.filter((e) => e.length > 0) .filter((e) => e.length > 0)
.forEach((row) => { .forEach((row) => {
// project data imports are on the column to the right // these fields contain the data to its right
let projectTitleNext = false; let projectTitleNext = false;
let projectDescriptionNext = false; let projectDescriptionNext = false;
let publicUrlNext = false; let publicUrlNext = false;
@@ -81,29 +116,31 @@ export const parseExcel = async (excelData) => {
const event: Partial<OntimeEvent> = {}; const event: Partial<OntimeEvent> = {};
row.forEach((column, j) => { row.forEach((column, j) => {
// check flags // 1. we check if we have set a flag for a known field
if (projectTitleNext) { if (projectTitleNext) {
projectData.title = column; projectData.title = makeString(column, '');
projectTitleNext = false; projectTitleNext = false;
} else if (projectDescriptionNext) { } else if (projectDescriptionNext) {
projectData.description = column; projectData.description = makeString(column, '');
projectDescriptionNext = false; projectDescriptionNext = false;
} else if (publicUrlNext) { } else if (publicUrlNext) {
projectData.publicUrl = column; projectData.publicUrl = makeString(column, '');
publicUrlNext = false; publicUrlNext = false;
} else if (publicInfoNext) { } else if (publicInfoNext) {
projectData.publicInfo = column; projectData.publicInfo = makeString(column, '');
publicInfoNext = false; publicInfoNext = false;
} else if (backstageUrlNext) { } else if (backstageUrlNext) {
projectData.backstageUrl = column; projectData.backstageUrl = makeString(column, '');
backstageUrlNext = false; backstageUrlNext = false;
} else if (backstageInfoNext) { } else if (backstageInfoNext) {
projectData.backstageInfo = column; projectData.backstageInfo = makeString(column, '');
backstageInfoNext = false; backstageInfoNext = false;
} else if (j === timeStartIndex) { } else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column); event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) { } else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column); event.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
event.duration = parseExcelDate(column);
} else if (j === titleIndex) { } else if (j === titleIndex) {
event.title = makeString(column, ''); event.title = makeString(column, '');
} else if (j === cueIndex) { } else if (j === cueIndex) {
@@ -119,155 +156,130 @@ export const parseExcel = async (excelData) => {
} else if (j === notesIndex) { } else if (j === notesIndex) {
event.note = makeString(column, ''); event.note = makeString(column, '');
} else if (j === endActionIndex) { } else if (j === endActionIndex) {
if (column === '') { event.endAction = validateEndAction(column);
event.endAction = EndAction.None;
} else {
event.endAction = column;
}
} else if (j === timerTypeIndex) { } else if (j === timerTypeIndex) {
if (column === '') { event.timerType = validateTimerType(column);
event.timerType = TimerType.CountDown;
} else {
event.timerType = column;
}
} else if (j === colourIndex) { } else if (j === colourIndex) {
event.colour = column; event.colour = makeString(column, '');
} else if (j === user0Index) { } else if (j === user0Index) {
event.user0 = column; event.user0 = makeString(column, '');
} else if (j === user1Index) { } else if (j === user1Index) {
event.user1 = column; event.user1 = makeString(column, '');
} else if (j === user2Index) { } else if (j === user2Index) {
event.user2 = column; event.user2 = makeString(column, '');
} else if (j === user3Index) { } else if (j === user3Index) {
event.user3 = column; event.user3 = makeString(column, '');
} else if (j === user4Index) { } else if (j === user4Index) {
event.user4 = column; event.user4 = makeString(column, '');
} else if (j === user5Index) { } else if (j === user5Index) {
event.user5 = column; event.user5 = makeString(column, '');
} else if (j === user6Index) { } else if (j === user6Index) {
event.user6 = column; event.user6 = makeString(column, '');
} else if (j === user7Index) { } else if (j === user7Index) {
event.user7 = column; event.user7 = makeString(column, '');
} else if (j === user8Index) { } else if (j === user8Index) {
event.user8 = column; event.user8 = makeString(column, '');
} else if (j === user9Index) { } else if (j === user9Index) {
event.user9 = column; event.user9 = makeString(column, '');
} else { } else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') { if (typeof column === 'string') {
const col = column.toLowerCase(); const col = column.toLowerCase();
// look for keywords // look for keywords
// need to make sure it is a string first // need to make sure it is a string first
switch (col) { switch (col) {
case 'project name': case importMap.projectName:
projectTitleNext = true; projectTitleNext = true;
break; break;
case 'project description': case importMap.projectDescription:
projectDescriptionNext = true; projectDescriptionNext = true;
break; break;
case 'public url': case importMap.publicUrl:
publicUrlNext = true; publicUrlNext = true;
break; break;
case 'public info': case importMap.publicInfo:
publicInfoNext = true; publicInfoNext = true;
break; break;
case 'backstage url': case importMap.backstageUrl:
backstageUrlNext = true; backstageUrlNext = true;
break; break;
case 'backstage info': case importMap.backstageInfo:
backstageInfoNext = true; backstageInfoNext = true;
break; break;
case 'time start': case importMap.timeStart:
case 'start':
timeStartIndex = j; timeStartIndex = j;
break; break;
case 'time end': case importMap.timeEnd:
case 'end':
case 'finish':
timeEndIndex = j; timeEndIndex = j;
break; break;
case 'cue': case importMap.duration:
case 'page': durationIndex = j;
break;
case importMap.cue:
cueIndex = j; cueIndex = j;
break; break;
case 'event title': case importMap.title:
case 'title':
titleIndex = j; titleIndex = j;
break; break;
case 'presenter name': case importMap.presenter:
case 'speaker':
case 'presenter':
presenterIndex = j; presenterIndex = j;
break; break;
case 'event subtitle': case importMap.subtitle:
case 'subtitle':
subtitleIndex = j; subtitleIndex = j;
break; break;
case 'is public? (x)': case importMap.isPublic:
case 'is public':
case 'public':
isPublicIndex = j; isPublicIndex = j;
break; break;
case 'skip? (x)': case importMap.skip:
case 'skip?':
case 'skip':
skipIndex = j; skipIndex = j;
break; break;
case 'note': case importMap.note:
case 'notes':
notesIndex = j; notesIndex = j;
break; break;
case 'colour': case importMap.colour:
case 'color':
colourIndex = j; colourIndex = j;
break; break;
case 'end action': case importMap.endAction:
endActionIndex = j; endActionIndex = j;
break; break;
case 'timer type': case importMap.timerType:
timerTypeIndex = j; timerTypeIndex = j;
break; break;
default: case importMap.user0:
// look for user defined user0Index = j;
if (col.startsWith('user')) {
const index = column.charAt(4);
// name is the bit after the :
const [, name] = column.split(':');
if (typeof name !== 'undefined') {
if (index === '0') {
customUserFields.user0 = name;
user0Index = j;
} else if (index === '1') {
customUserFields.user1 = name;
user1Index = j;
} else if (index === '2') {
customUserFields.user2 = name;
user2Index = j;
} else if (index === '3') {
customUserFields.user3 = name;
user3Index = j;
} else if (index === '4') {
customUserFields.user4 = name;
user4Index = j;
} else if (index === '5') {
customUserFields.user5 = name;
user5Index = j;
} else if (index === '6') {
customUserFields.user6 = name;
user6Index = j;
} else if (index === '7') {
customUserFields.user7 = name;
user7Index = j;
} else if (index === '8') {
customUserFields.user8 = name;
user8Index = j;
} else if (index === '9') {
customUserFields.user9 = name;
user9Index = j;
}
}
}
break; break;
case importMap.user1:
user1Index = j;
break;
case importMap.user2:
user2Index = j;
break;
case importMap.user3:
user3Index = j;
break;
case importMap.user4:
user4Index = j;
break;
case importMap.user5:
user5Index = j;
break;
case importMap.user6:
user6Index = j;
break;
case importMap.user7:
user7Index = j;
break;
case importMap.user8:
user8Index = j;
break;
case importMap.user9:
user9Index = j;
break;
default:
// we don't know how to handle this column
// just ignore it
} }
} }
} }
@@ -275,10 +287,10 @@ export const parseExcel = async (excelData) => {
if (Object.keys(event).length > 0) { if (Object.keys(event).length > 0) {
// if any data was found, push to array // if any data was found, push to array
// take care of it in the next step
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent); rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
} }
}); });
return { return {
rundown, rundown,
project: projectData, project: projectData,
@@ -286,17 +298,16 @@ export const parseExcel = async (excelData) => {
app: 'ontime', app: 'ontime',
version: 2, version: 2,
}, },
userFields: { ...dbModel.userFields, ...customUserFields }, userFields: customUserFields,
}; };
}; };
/** /**
* @description JSON parser function for v1 of data system * @description JSON parser function for ontime project file
* @param {object} jsonData - json data JSON object to be parsed * @param {object} jsonData - project file to be parsed
* @param {boolean} [enforce=false] - flag, tells to create an object anyway
* @returns {object} - parsed object * @returns {object} - parsed object
*/ */
export const parseJson = async (jsonData, enforce = false): Promise<DatabaseModel | null> => { export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
if (!jsonData || typeof jsonData !== 'object') { if (!jsonData || typeof jsonData !== 'object') {
return null; return null;
} }
@@ -307,17 +318,17 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
// parse Events // parse Events
returnData.rundown = parseRundown(jsonData); returnData.rundown = parseRundown(jsonData);
// parse Event // parse Event
returnData.project = parseProject(jsonData, enforce); returnData.project = parseProject(jsonData) ?? dbModel.project;
// Settings handled partially // Settings handled partially
returnData.settings = parseSettings(jsonData, enforce); returnData.settings = parseSettings(jsonData) ?? dbModel.settings;
// View settings handled partially // View settings handled partially
returnData.viewSettings = parseViewSettings(jsonData, enforce); returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings;
// Import Aliases if any // Import Aliases if any
returnData.aliases = parseAliases(jsonData); returnData.aliases = parseAliases(jsonData);
// Import user fields if any // Import user fields if any
returnData.userFields = parseUserFields(jsonData); returnData.userFields = parseUserFields(jsonData);
// Import OSC settings if any // Import OSC settings if any
returnData.osc = parseOsc(jsonData, enforce); returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
// Import HTTP settings if any // Import HTTP settings if any
// returnData.http = parseHttp(jsonData, enforce); // returnData.http = parseHttp(jsonData, enforce);
@@ -344,19 +355,19 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
const e = eventArgs; const e = eventArgs;
const d = eventDef; const d = eventDef;
const start = e.timeStart != null && typeof e.timeStart === 'number' ? e.timeStart : d.timeStart;
const end = e.timeEnd != null && typeof e.timeEnd === 'number' ? e.timeEnd : d.timeEnd; const { timeStart, timeEnd, duration } = validateTimes(e.timeStart, e.timeEnd, e.duration);
event = { event = {
...d, ...d,
title: makeString(e.title, d.title), title: makeString(e.title, d.title),
subtitle: makeString(e.subtitle, d.subtitle), subtitle: makeString(e.subtitle, d.subtitle),
presenter: makeString(e.presenter, d.presenter), presenter: makeString(e.presenter, d.presenter),
timeStart: start, timeStart,
timeEnd: end, timeEnd,
endAction: makeString(e.endAction, d.endAction), duration,
timerType: makeString(e.timerType, d.timerType), endAction: validateEndAction(e.endAction, EndAction.None),
duration: calculateDuration(start, end), timerType: validateTimerType(e.timerType, TimerType.CountDown),
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic, isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
skip: typeof e.skip === 'boolean' ? e.skip : d.skip, skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
note: makeString(e.note, d.note), note: makeString(e.note, d.note),
@@ -371,8 +382,8 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
user8: makeString(e.user8, d.user8), user8: makeString(e.user8, d.user8),
user9: makeString(e.user9, d.user9), user9: makeString(e.user9, d.user9),
colour: makeString(e.colour, d.colour), colour: makeString(e.colour, d.colour),
id,
cue: makeString(e.cue, cueFallback), cue: makeString(e.cue, cueFallback),
id,
type: 'event', type: 'event',
}; };
} }
@@ -380,68 +391,56 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
return event; return event;
}; };
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' }; type ResponseOK = {
type ResponseError = { error: true; message: string }; data: Partial<DatabaseModel>;
};
/** /**
* @description Middleware function that checks file type and calls relevant parser * @description Middleware function that checks file type and calls relevant parser
* @param {string} file - reference to file * @param {string} file - reference to file
* @param options - import options
* @return {object} - parse result message * @return {object} - parse result message
*/ */
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => { export const fileHandler = async (file: string, options: ExcelImportOptions): Promise<Partial<ResponseOK>> => {
let res: Partial<ResponseOK | ResponseError> = {}; const res: Partial<ResponseOK> = {};
// check which file type are we dealing with // check which file type are we dealing with
if (file.endsWith('.xlsx')) { if (file.endsWith('.xlsx')) {
try { // we need to check that the options are applicable
const excelData = xlsx if (!isExcelImportMap(options)) {
.parse(file, { cellDates: true }) throw new Error('Got incorrect options to excel import', JSON.parse(options));
.find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule'); }
// we only look at worksheets called ontime or event schedule const excelData = xlsx
if (excelData?.data) { .parse(file, { cellDates: true })
const dataFromExcel = await parseExcel(excelData.data); .find(({ name }) => name.toLowerCase() === options.worksheet);
res.data = {};
res.data.rundown = parseRundown(dataFromExcel); if (excelData?.data) {
res.data.project = parseProject(dataFromExcel, true); const dataFromExcel = parseExcel(excelData.data, options);
res.data.userFields = parseUserFields(dataFromExcel); // we run the parsed data through an extra step to ensure the objects shape
res.message = 'success'; res.data = {};
} else { res.data.rundown = parseRundown(dataFromExcel);
const errorMessage = 'No sheet found named "ontime" or "event schedule"'; res.data.project = parseProject(dataFromExcel);
res = { res.data.userFields = parseUserFields(dataFromExcel);
error: true, return res;
message: errorMessage, } else {
}; throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
}
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
} }
} }
if (file.endsWith('.json')) { if (file.endsWith('.json')) {
// if json check version // if json check version
const rawdata = fs.readFileSync(file); const rawdata = fs.readFileSync(file).toString();
let uploadedJson = null; let uploadedJson = null;
try { uploadedJson = JSON.parse(rawdata);
uploadedJson = JSON.parse(rawdata); if (uploadedJson.settings.version !== 2) {
} catch (error) { throw new Error(`Project version unknown ${uploadedJson.settings.version}`);
return { error: true, message: 'Error parsing JSON file' };
} }
res.data = await parseJson(uploadedJson);
if (uploadedJson.settings.version === 2) { // delete file
try { await deleteFile(file);
res.data = await parseJson(uploadedJson); return res;
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
await deleteFile(file);
return res;
}; };
+7 -44
View File
@@ -1,7 +1,6 @@
import { generateId } from 'ontime-utils'; import { generateId } from 'ontime-utils';
import { import {
Alias, Alias,
EndAction,
OntimeRundown, OntimeRundown,
OSCSettings, OSCSettings,
OscSubscription, OscSubscription,
@@ -9,7 +8,6 @@ import {
ProjectData, ProjectData,
Settings, Settings,
TimerLifeCycle, TimerLifeCycle,
TimerType,
UserFields, UserFields,
ViewSettings, ViewSettings,
} from 'ontime-types'; } from 'ontime-types';
@@ -45,18 +43,6 @@ export const parseRundown = (data): OntimeRundown => {
continue; continue;
} }
// validate the right endAction is used
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
e.endAction = EndAction.None;
console.log('WARNING: invalid End Action provided, using default');
}
// validate the right timerType is used
if (e.timerType && !Object.values(TimerType).includes(e.timerType)) {
e.timerType = TimerType.CountDown;
console.log('WARNING: invalid Timer Type provided, using default');
}
if (e.type === 'event') { if (e.type === 'event') {
eventIndex += 1; eventIndex += 1;
const event = validateEvent(e, eventIndex.toString()); const event = validateEvent(e, eventIndex.toString());
@@ -88,10 +74,9 @@ export const parseRundown = (data): OntimeRundown => {
/** /**
* Parse event portion of an entry * Parse event portion of an entry
* @param {object} data - data object * @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data * @returns {object} - event object data
*/ */
export const parseProject = (data, enforce): ProjectData => { export const parseProject = (data): ProjectData => {
let newProjectData: Partial<ProjectData> = {}; let newProjectData: Partial<ProjectData> = {};
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated // we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
// TODO: Remove eventually // TODO: Remove eventually
@@ -109,9 +94,6 @@ export const parseProject = (data, enforce): ProjectData => {
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl, backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo, backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
}; };
} else if (enforce) {
newProjectData = { ...dbModel.project };
console.log('Created project object in db');
} }
return newProjectData as ProjectData; return newProjectData as ProjectData;
}; };
@@ -119,10 +101,9 @@ export const parseProject = (data, enforce): ProjectData => {
/** /**
* Parse settings portion of an entry * Parse settings portion of an entry
* @param {object} data - data object * @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data * @returns {object} - event object data
*/ */
export const parseSettings = (data, enforce): Settings => { export const parseSettings = (data): Settings => {
let newSettings: Partial<Settings> = {}; let newSettings: Partial<Settings> = {};
if ('settings' in data) { if ('settings' in data) {
console.log('Found settings definition, importing...'); console.log('Found settings definition, importing...');
@@ -146,9 +127,6 @@ export const parseSettings = (data, enforce): Settings => {
...settings, ...settings,
}; };
} }
} else if (enforce) {
newSettings = dbModel.settings;
console.log('Created settings object in db');
} }
return newSettings as Settings; return newSettings as Settings;
}; };
@@ -156,10 +134,9 @@ export const parseSettings = (data, enforce): Settings => {
/** /**
* Parse settings portion of an entry * Parse settings portion of an entry
* @param {object} data - data object * @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data * @returns {object} - event object data
*/ */
export const parseViewSettings = (data, enforce): ViewSettings => { export const parseViewSettings = (data): ViewSettings => {
let newViews: Partial<ViewSettings> = {}; let newViews: Partial<ViewSettings> = {};
if ('viewSettings' in data) { if ('viewSettings' in data) {
console.log('Found view definition, importing...'); console.log('Found view definition, importing...');
@@ -175,13 +152,7 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage, endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
}; };
// write to db newViews = { ...viewSettings };
newViews = {
...viewSettings,
};
} else if (enforce) {
newViews = dbModel.viewSettings;
console.log('Created viewSettings object in db');
} }
return newViews as ViewSettings; return newViews as ViewSettings;
}; };
@@ -224,16 +195,11 @@ export const validateOscObject = (data: OscSubscription): boolean => {
/** /**
* Parse osc portion of an entry * Parse osc portion of an entry
*/ */
export const parseOsc = ( export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
data: {
osc?: Partial<OSCSettings>;
},
enforce: boolean,
): OSCSettings | Record<string, never> => {
if ('osc' in data) { if ('osc' in data) {
console.log('Found OSC definition, importing...'); console.log('Found OSC definition, importing...');
const loadedConfig = data?.osc || {}; const loadedConfig = data.osc || {};
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions) const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions ? loadedConfig.subscriptions
: dbModel.osc.subscriptions; : dbModel.osc.subscriptions;
@@ -246,10 +212,7 @@ export const parseOsc = (
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut, enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
subscriptions: validatedSubscriptions, subscriptions: validatedSubscriptions,
}; };
} else if (enforce) { }
console.log('Created OSC object in db');
return { ...dbModel.osc };
} else return {};
}; };
/** /**
+23 -8
View File
@@ -1,4 +1,5 @@
import fs from 'fs'; import fs from 'fs';
import { deepmerge } from 'ontime-utils';
/** /**
* @description Ensures variable is string, it skips object types * @description Ensures variable is string, it skips object types
@@ -52,16 +53,30 @@ export const isEmptyObject = (obj: object) => {
/** /**
* @description Merges two objects, suppressing undefined keys * @description Merges two objects, suppressing undefined keys
* @param {object} a * @param {object} a - any object
* @param {object} b * @param {object} b - a potential partial object of same time as a
*/ */
export const mergeObject = (a, b) => { export function mergeObject<T extends Record<string, any>>(a: T, b: Partial<Record<keyof T, any>>): T {
const merged = {}; const merged = { ...a };
Object.keys({ ...a, ...b }).map((key) => {
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key]; for (const key in b) {
}); const aValue = a[key];
const bValue = b[key];
// ignore keys that do not exist in original object
if (!Object.hasOwn(merged, key)) {
continue;
}
if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) {
// @ts-expect-error -- library side, ignore for now
merged[key] = deepmerge(aValue, bValue);
} else if (bValue !== undefined) {
merged[key] = bValue;
}
}
return merged; return merged;
}; }
/** /**
* @description Removes undefined * @description Removes undefined
+11 -7
View File
@@ -89,14 +89,18 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
* @returns {number} - time in milliseconds * @returns {number} - time in milliseconds
*/ */
export const parseExcelDate = (excelDate: string): number => { export const parseExcelDate = (excelDate: unknown): number => {
// attempt converting to date object if (excelDate instanceof Date) {
const date = new Date(excelDate); return dateToMillis(excelDate);
if (date instanceof Date && !isNaN(date.getTime())) { } else if (typeof excelDate === 'string') {
return dateToMillis(date); const date = new Date(excelDate);
} else if (isTimeString(excelDate)) { if (date instanceof Date && !isNaN(date.getTime())) {
return forgivingStringToMillis(excelDate); return dateToMillis(date);
} else if (isTimeString(excelDate)) {
return forgivingStringToMillis(excelDate);
}
} }
return 0; return 0;
}; };
+18 -1
View File
@@ -1,12 +1,13 @@
// runtime utils // runtime utils
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js'; export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { validatePlayback } from './src/validate-action/validatePlayback.js'; export { validatePlayback } from './src/validate-action/validatePlayback.js';
export { validateTimes } from './src/validate-events/validateEvent.js';
export { calculateDuration } from './src/validate-events/validateEvent.js';
// rundown utils // rundown utils
export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js'; export { generateId } from './src/generate-id/generateId.js';
export { calculateDuration } from './src/rundown-utils/rundownUtils.js';
export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js'; export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js';
// format utils // format utils
@@ -18,5 +19,21 @@ export { millisToString } from './src/date-utils/millisToString.js';
// time utils // time utils
export { dayInMs, mts } from './src/timeConstants.js'; export { dayInMs, mts } from './src/timeConstants.js';
// helpers from externals
export { deepmerge } from './src/externals/deepmerge.js';
// generic utilities // generic utilities
export { isNumeric } from './src/types/types.js'; export { isNumeric } from './src/types/types.js';
// model validation
export { validateEndAction, validateTimerType } from './src/validate-events/validateEvent.js';
// feature business logic
// feature business logic - excel import
export {
type ExcelImportMap,
type ExcelImportOptions,
defaultExcelImportMap,
isExcelImportMap,
} from './src/feature/excel-import/excelImport.js';
+1
View File
@@ -11,6 +11,7 @@
"cleanup": "rm -rf .turbo && rm -rf node_modules" "cleanup": "rm -rf .turbo && rm -rf node_modules"
}, },
"dependencies": { "dependencies": {
"deepmerge-ts": "^5.1.0",
"luxon": "^3.3.0", "luxon": "^3.3.0",
"nanoid": "^4.0.1" "nanoid": "^4.0.1"
}, },
+2
View File
@@ -0,0 +1,2 @@
// evaluating the library, we re-export to make it easy to detach
export { deepmerge } from 'deepmerge-ts';
@@ -0,0 +1,44 @@
export type ExcelImportOptions = keyof typeof defaultExcelImportMap;
export type ExcelImportMap = typeof defaultExcelImportMap;
export const defaultExcelImportMap = {
worksheet: 'event schedule',
projectName: 'project name',
projectDescription: 'project description',
publicUrl: 'public url',
publicInfo: 'public info',
backstageUrl: 'backstage url',
backstageInfo: 'backstage info',
timeStart: 'time start',
timeEnd: 'time end',
duration: 'duration',
cue: 'cue',
title: 'title',
presenter: 'presenter',
subtitle: 'subtitle',
isPublic: 'public',
skip: 'skip',
note: 'notes',
colour: 'colour',
endAction: 'end action',
timerType: 'timer type',
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
};
export function isExcelImportMap(obj: unknown): obj is ExcelImportMap {
if (typeof obj !== 'object' || obj === null) {
return false;
}
const keys = Object.keys(obj);
return keys.every((key) => Object.hasOwn(defaultExcelImportMap, key));
}
@@ -2,7 +2,6 @@ import { OntimeRundown, SupportedEvent } from 'ontime-types';
import { dayInMs } from '../timeConstants.js'; import { dayInMs } from '../timeConstants.js';
import { getNextEvent, getPreviousEvent } from './rundownUtils'; import { getNextEvent, getPreviousEvent } from './rundownUtils';
import { calculateDuration } from './rundownUtils.js';
describe('getNextEvent()', () => { describe('getNextEvent()', () => {
it('returns the next event of type event', () => { it('returns the next event of type event', () => {
@@ -71,25 +70,3 @@ describe('getPreviousEvent()', () => {
expect(previous).toBe(null); expect(previous).toBe(null);
}); });
}); });
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('is the difference between end and start', () => {
const duration = calculateDuration(10, 20);
expect(duration).toBe(10);
});
});
describe('Handles edge cases', () => {
it('handles events that go over midnight', () => {
const duration = calculateDuration(51, 50);
expect(duration).toBe(dayInMs - 1);
});
it('handles no difference', () => {
const duration1 = calculateDuration(0, 0);
const duration2 = calculateDuration(dayInMs, dayInMs);
expect(duration1).toBe(0);
expect(duration2).toBe(0);
});
});
});
@@ -1,7 +1,5 @@
import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import { dayInMs } from '../timeConstants.js';
/** /**
* Gets first event in rundown, if it exists * Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown * @param {OntimeRundownEntry[]} rundown
@@ -113,20 +111,6 @@ export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: strin
return null; return null;
} }
/**
* @description calculates event duration considering midnight
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export const calculateDuration = (timeStart: number, timeEnd: number): number => {
// Durations must be positive
if (timeEnd < timeStart) {
return timeEnd + dayInMs - timeStart;
}
return timeEnd - timeStart;
};
/** /**
* @description swaps two OntimeEvents in the rundown * @description swaps two OntimeEvents in the rundown
* @param {OntimeRundown} rundown * @param {OntimeRundown} rundown
@@ -0,0 +1,118 @@
import { EndAction, TimerType } from 'ontime-types';
import { expect } from 'vitest';
import { dayInMs } from '../timeConstants.js';
import { calculateDuration, validateEndAction, validateTimerType, validateTimes } from './validateEvent.js';
describe('validateEndAction()', () => {
it('recognises a string representation of an action', () => {
const endAction = validateEndAction('load-next');
expect(endAction).toBe(EndAction.LoadNext);
});
it('returns fallback otherwise', () => {
const emptyAction = validateEndAction('', EndAction.Stop);
const invalidAction = validateEndAction('this-does-not-exist', EndAction.PlayNext);
expect(emptyAction).toBe(EndAction.Stop);
expect(invalidAction).toBe(EndAction.PlayNext);
});
});
describe('validateTimerType()', () => {
it('recognises a string representation of an action', () => {
const timerType = validateTimerType('time-to-end');
expect(timerType).toBe(TimerType.TimeToEnd);
});
it('returns fallback otherwise', () => {
const emptyType = validateTimerType('', TimerType.Clock);
const invalidType = validateTimerType('this-does-not-exist', TimerType.CountDown);
expect(emptyType).toBe(TimerType.Clock);
expect(invalidType).toBe(TimerType.CountDown);
});
});
describe('validateTimes()', () => {
it('passes through a well defined time list', () => {
const { timeStart, timeEnd, duration } = validateTimes(5, 10, 5);
expect(timeStart).toBe(5);
expect(timeEnd).toBe(10);
expect(duration).toBe(5);
});
it('handles cases when no times are given', () => {
const { timeStart, timeEnd, duration } = validateTimes(null, undefined, null);
expect(timeStart).toBe(0);
expect(timeEnd).toBe(0);
expect(duration).toBe(0);
});
it('calculates duration', () => {
const { timeStart, timeEnd, duration } = validateTimes(5, 10);
expect(timeStart).toBe(5);
expect(timeEnd).toBe(10);
expect(duration).toBe(5);
});
it('calculates end time', () => {
const { timeStart, timeEnd, duration } = validateTimes(5, undefined, 10);
expect(timeStart).toBe(5);
expect(timeEnd).toBe(15);
expect(duration).toBe(10);
});
it('handles events that finish the day after', () => {
const { timeStart, timeEnd, duration } = validateTimes(100, 10);
expect(timeStart).toBe(100);
expect(timeEnd).toBe(10);
expect(duration).toBe(dayInMs - 90);
});
it('corrects time in case of conflicts', () => {
const { timeStart, timeEnd, duration } = validateTimes(5, 15, 15);
expect(timeStart).toBe(5);
expect(timeEnd).toBe(15);
expect(duration).toBe(10);
});
it('calculates start time', () => {
const { timeStart, timeEnd, duration } = validateTimes(undefined, 15, 10);
expect(timeStart).toBe(5);
expect(timeEnd).toBe(15);
expect(duration).toBe(10);
});
it('calculates start and end time', () => {
const { timeStart, timeEnd, duration } = validateTimes(undefined, undefined, 10);
expect(timeStart).toBe(0);
expect(timeEnd).toBe(10);
expect(duration).toBe(10);
});
it('ensures values are integers', () => {
const { timeStart, timeEnd, duration } = validateTimes(0.000001, 10.312335342, 10);
expect(timeStart).toBe(0);
expect(timeEnd).toBe(10);
expect(duration).toBe(10);
});
});
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('is the difference between end and start', () => {
const duration = calculateDuration(10, 20);
expect(duration).toBe(10);
});
});
describe('Handles edge cases', () => {
it('handles events that go over midnight', () => {
const duration = calculateDuration(51, 50);
expect(duration).toBe(dayInMs - 1);
});
it('handles no difference', () => {
const duration1 = calculateDuration(0, 0);
const duration2 = calculateDuration(dayInMs, dayInMs);
expect(duration1).toBe(0);
expect(duration2).toBe(0);
});
});
});
@@ -0,0 +1,79 @@
import { EndAction, TimerType } from 'ontime-types';
import { dayInMs } from '../timeConstants.js';
export function validateEndAction(maybeAction: unknown, fallback = EndAction.None) {
if (typeof maybeAction !== 'string') {
return fallback;
}
const isAction = Object.values(EndAction).includes(maybeAction as EndAction);
if (isAction) {
return maybeAction as EndAction;
}
return fallback;
}
export function validateTimerType(maybeTimerType: unknown, fallback = TimerType.CountDown) {
if (typeof maybeTimerType !== 'string') {
return fallback;
}
const isTimerType = Object.values(TimerType).includes(maybeTimerType as TimerType);
if (isTimerType) {
return maybeTimerType as TimerType;
}
return fallback;
}
/**
* @description calculates event duration considering midnight
* @param {number} timeStart
* @param {number} timeEnd
* @returns {number}
*/
export const calculateDuration = (timeStart: number, timeEnd: number): number => {
// Durations must be positive
if (timeEnd < timeStart) {
return timeEnd + dayInMs - timeStart;
}
return timeEnd - timeStart;
};
function convertToInteger(value: unknown): number {
const result = Number(value);
return isNaN(result) ? 0 : Math.floor(result);
}
export function validateTimes(_start?: unknown, _end?: unknown, _duration?: unknown) {
const timeStart = convertToInteger(_start);
const timeEnd = convertToInteger(_end);
const duration = convertToInteger(_duration);
if (_start != null && _end != null) {
// Case 1. if we have start and end, duration must be derived
return { timeStart, duration: calculateDuration(timeStart, timeEnd), timeEnd };
}
if (_start == null && _end == null) {
if (_duration == null) {
// Case 2. no valid times were given
return { timeStart, duration, timeEnd };
}
// Case 3. we have a duration and infer the rest
return { timeStart, duration, timeEnd: duration };
}
if (_start != null) {
// Case 5. with only start, we can calculate the rest
return { timeStart, duration, timeEnd: timeStart + duration };
}
if (_end != null) {
// Case 6. with only end, we can calculate the rest
return { timeStart: timeEnd - duration, duration, timeEnd };
}
// we should have covered all cases
return { timeStart, duration, timeEnd };
}
+31 -88
View File
@@ -188,7 +188,7 @@ importers:
lowdb: ^5.0.5 lowdb: ^5.0.5
multer: ^1.4.5-lts.1 multer: ^1.4.5-lts.1
node-osc: ^8.0.10 node-osc: ^8.0.10
node-xlsx: ^0.21.0 node-xlsx: ^0.23.0
nodemon: ^2.0.20 nodemon: ^2.0.20
ontime-types: workspace:* ontime-types: workspace:*
ontime-utils: workspace:* ontime-utils: workspace:*
@@ -211,7 +211,7 @@ importers:
lowdb: 5.0.5 lowdb: 5.0.5
multer: 1.4.5-lts.1 multer: 1.4.5-lts.1
node-osc: 8.0.10 node-osc: 8.0.10
node-xlsx: 0.21.0 node-xlsx: 0.23.0
ontime-utils: link:../../packages/utils ontime-utils: link:../../packages/utils
passport: 0.6.0 passport: 0.6.0
passport-local: 1.0.0 passport-local: 1.0.0
@@ -251,6 +251,7 @@ importers:
'@types/luxon': ^3.2.0 '@types/luxon': ^3.2.0
'@typescript-eslint/eslint-plugin': ^5.48.1 '@typescript-eslint/eslint-plugin': ^5.48.1
'@typescript-eslint/parser': ^5.48.1 '@typescript-eslint/parser': ^5.48.1
deepmerge-ts: ^5.1.0
eslint: ^8.31.0 eslint: ^8.31.0
eslint-config-prettier: ^8.6.0 eslint-config-prettier: ^8.6.0
eslint-plugin-prettier: ^4.2.1 eslint-plugin-prettier: ^4.2.1
@@ -262,6 +263,7 @@ importers:
typescript: ^4.9.4 typescript: ^4.9.4
vitest: ^0.30.1 vitest: ^0.30.1
dependencies: dependencies:
deepmerge-ts: 5.1.0
luxon: 3.3.0 luxon: 3.3.0
nanoid: 4.0.1 nanoid: 4.0.1
devDependencies: devDependencies:
@@ -3289,20 +3291,6 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/adler-32/1.2.0:
resolution: {integrity: sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==}
engines: {node: '>=0.8'}
hasBin: true
dependencies:
exit-on-epipe: 1.0.1
printj: 1.1.2
dev: false
/adler-32/1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
dev: false
/agent-base/6.0.2: /agent-base/6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'} engines: {node: '>= 6.0.0'}
@@ -3751,14 +3739,6 @@ packages:
resolution: {integrity: sha512-ecER9xgJQVMqcrxThKptsW0pPxSae8R2RB87LNa+ivW9ppNWRHEplXcDzkCOP4LYWGj8hunXLqaiC41iBATNyg==} resolution: {integrity: sha512-ecER9xgJQVMqcrxThKptsW0pPxSae8R2RB87LNa+ivW9ppNWRHEplXcDzkCOP4LYWGj8hunXLqaiC41iBATNyg==}
dev: true dev: true
/cfb/1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
dev: false
/chai/4.3.7: /chai/4.3.7:
resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==} resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -3812,7 +3792,7 @@ packages:
normalize-path: 3.0.0 normalize-path: 3.0.0
readdirp: 3.6.0 readdirp: 3.6.0
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/chownr/2.0.0: /chownr/2.0.0:
@@ -3854,11 +3834,6 @@ packages:
mimic-response: 1.0.1 mimic-response: 1.0.1
dev: true dev: true
/codepage/1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
dev: false
/color-convert/1.9.3: /color-convert/1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies: dependencies:
@@ -4024,12 +3999,6 @@ packages:
path-type: 4.0.0 path-type: 4.0.0
yaml: 1.10.2 yaml: 1.10.2
/crc-32/1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
dev: false
/crc/3.8.0: /crc/3.8.0:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
requiresBuild: true requiresBuild: true
@@ -4189,6 +4158,11 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true dev: true
/deepmerge-ts/5.1.0:
resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==}
engines: {node: '>=16.0.0'}
dev: false
/deepmerge/4.3.0: /deepmerge/4.3.0:
resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==} resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -4860,11 +4834,6 @@ packages:
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
dev: false dev: false
/exit-on-epipe/1.0.1:
resolution: {integrity: sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==}
engines: {node: '>=0.8'}
dev: false
/expect/29.3.1: /expect/29.3.1:
resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==} resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5102,11 +5071,6 @@ packages:
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
dev: false dev: false
/frac/1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
dev: false
/framer-motion/10.11.2_biqbaboplfbrettd7655fr4n2y: /framer-motion/10.11.2_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-IrwuC9regNOU99JoM/Z62CAMA3awGV6AcF7e3bcgXk/ZoNlGSt5aVq0J7UAwtLmCkwVlRvBkiMnvv2mZ1GW2pg==} resolution: {integrity: sha512-IrwuC9regNOU99JoM/Z62CAMA3awGV6AcF7e3bcgXk/ZoNlGSt5aVq0J7UAwtLmCkwVlRvBkiMnvv2mZ1GW2pg==}
peerDependencies: peerDependencies:
@@ -5183,6 +5147,14 @@ packages:
dev: true dev: true
optional: true optional: true
/fsevents/2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/function-bind/1.1.1: /function-bind/1.1.1:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
@@ -6287,12 +6259,12 @@ packages:
resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==} resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==}
dev: true dev: true
/node-xlsx/0.21.0: /node-xlsx/0.23.0:
resolution: {integrity: sha512-MB+KcNCuRzwjgr17scpKiVTPd4Vbj3V+7QwKpqACGyJzhvC67xCQUbw2vYEIKtNfMfcLxgB2q2kEuRS8rmak9g==} resolution: {integrity: sha512-r3KaSZSsSrK92rbPXnX/vDdxURmPPik0rjJ3A+Pybzpjyrk4G6WyGfj8JIz5dMMEpCmWVpmO4qoVPBxnpLv/8Q==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
hasBin: true hasBin: true
dependencies: dependencies:
xlsx: 0.17.5 xlsx: '@cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz'
dev: false dev: false
/nodemon/2.0.20: /nodemon/2.0.20:
@@ -6648,12 +6620,6 @@ packages:
react-is: 18.2.0 react-is: 18.2.0
dev: true dev: true
/printj/1.1.2:
resolution: {integrity: sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==}
engines: {node: '>=0.8'}
hasBin: true
dev: false
/process-nextick-args/2.0.1: /process-nextick-args/2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: false dev: false
@@ -7056,7 +7022,7 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'} engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/run-parallel/1.2.0: /run-parallel/1.2.0:
@@ -7295,13 +7261,6 @@ packages:
dev: true dev: true
optional: true optional: true
/ssf/0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
dependencies:
frac: 1.1.2
dev: false
/stack-utils/2.0.6: /stack-utils/2.0.6:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8029,7 +7988,7 @@ packages:
postcss: 8.4.21 postcss: 8.4.21
rollup: 3.20.7 rollup: 3.20.7
optionalDependencies: optionalDependencies:
fsevents: 2.3.2 fsevents: 2.3.3
dev: true dev: true
/vite/4.3.1_c45e4f2cmsrvgdqihjqb2lwfbq: /vite/4.3.1_c45e4f2cmsrvgdqihjqb2lwfbq:
@@ -8341,21 +8300,11 @@ packages:
stackback: 0.0.2 stackback: 0.0.2
dev: true dev: true
/wmf/1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
dev: false
/word-wrap/1.2.3: /word-wrap/1.2.3:
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dev: true dev: true
/word/0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
dev: false
/wrap-ansi/7.0.0: /wrap-ansi/7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8395,20 +8344,6 @@ packages:
optional: true optional: true
dev: false dev: false
/xlsx/0.17.5:
resolution: {integrity: sha512-lXNU0TuYsvElzvtI6O7WIVb9Zar1XYw7Xb3VAx2wn8N/n0whBYrCnHMxtFyIiUU1Wjf09WzmLALDfBO5PqTb1g==}
engines: {node: '>=0.8'}
hasBin: true
dependencies:
adler-32: 1.2.0
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
dev: false
/xml-name-validator/4.0.0: /xml-name-validator/4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -8501,3 +8436,11 @@ packages:
react: 18.2.0 react: 18.2.0
use-sync-external-store: 1.2.0_react@18.2.0 use-sync-external-store: 1.2.0_react@18.2.0
dev: false dev: false
'@cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz':
resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz}
name: xlsx
version: 0.19.3
engines: {node: '>=0.8'}
hasBin: true
dev: false