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 { generateId, millisToString } from 'ontime-utils';
import { ontimeQueryClient } from '../queryClient';
import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
let message;
export function maybeAxiosError(error: unknown) {
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
let data = (error as AxiosError).response?.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 {
message = `${prepend}: ${error}`;
return error as string;
}
}
export function logAxiosError(prepend: string, error: unknown) {
const message = `${prepend}: ${maybeAxiosError(error)}`;
addLog({
id: generateId(),
@@ -23,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) {
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 { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
import axios, { AxiosResponse } from 'axios';
import {
Alias,
DatabaseModel,
OntimeRundown,
OSCSettings,
OscSubscription,
ProjectData,
Settings,
UserFields,
ViewSettings,
} from 'ontime-types';
import { ExcelImportMap } from 'ontime-utils';
import { apiRepoLatest } from '../../externals';
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
* @return {Promise}
*/
type UploadDataOptions = {
onlyRundown?: boolean;
};
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
export const uploadProjectFile = async (
file: File,
setProgress: (value: number) => void,
options?: Partial<ProjectFileImportOptions>,
) => {
const formData = new FormData();
formData.append('userFile', file);
const onlyRundown = options?.onlyRundown || 'false';
const onlyRundown = Boolean(options?.onlyRundown);
console.log('debug here', onlyRundown, options);
await axios
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
headers: {
@@ -161,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
.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 = {
url: string;
version: string;
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
import Swatch from './Swatch';
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
interface ColourInputProps {
value: string;
name: TitleActions;
handleChange: (newValue: TitleActions, name: string) => void;
name: EditorUpdateFields;
handleChange: (newValue: EditorUpdateFields, name: string) => void;
}
const colours = [
@@ -38,6 +38,12 @@
gap: $element-inner-spacing;
}
.noHover {
&:hover {
background-color: inherit;
}
}
.title {
font-size: $inner-section-text-size;
display: block;
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorDataLeft';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextAreaProps {
field: TitleActions;
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextArea(props: CountedTextAreaProps) {
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
.title {
font-size: $inner-section-text-size;
color: $gray-500;
padding-left: 8px;
margin: 8px 0;
padding-left: 0.5rem;
margin: 0.5rem 0;
text-transform: uppercase;
}
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
color: $error-red;
}
.success {
@include subsection;
color: $action-blue;
}
.feedbackSection {
justify-content: flex-start;
}
.buttonSection {
margin-top: $section-spacing;
display: flex;
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
flex-grow: 1;
}
.vSpacer {
height: 2rem;
}
.shiftRight {
align-self: flex-end;
}
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
grid-template-columns: auto 1fr;
}
.twoEqualColumn {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.padBottom {
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 {
display: flex;
flex-direction: column;
gap: 16px;
gap: 1rem;
}
.uploadArea {
margin: 0 auto;
width: 100%;
min-height: 200px;
border: 2px dashed $gray-50;
max-width: 550px;
min-height: 150px;
border: 2px dashed $gray-200;
border-radius: 3px;
display: grid;
place-content: center;
transition-property: background-color;
transition-duration: $transition-time-action;
font-size: calc(1rem - 1px);
&:hover {
border: 2px solid $blue-500;
background-color: $blue-50;
cursor: pointer;
}
&.comment {
color: gray;
}
}
.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;
}
color: $modal-note-color;
}
}
@@ -89,3 +41,9 @@
.pad {
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 {
Button,
Input,
Modal,
ModalBody,
ModalCloseButton,
@@ -9,22 +8,33 @@ import {
ModalFooter,
ModalHeader,
ModalOverlay,
Progress,
Switch,
} 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 { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
import { uploadData } from '../../../common/api/ontimeApi';
import { useEmitLog } from '../../../common/stores/logger';
import ModalSplitInput from '../ModalSplitInput';
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
import {
patchData,
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 { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
export type UploadStep = 'upload' | 'review';
interface UploadModalProps {
onClose: () => void;
@@ -33,64 +43,130 @@ interface UploadModalProps {
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
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 fileUploaded = event?.target?.files?.[0];
if (!fileUploaded) return;
const { file, setProgress, clear } = useUploadModalContextStore();
const validate = validateFile(fileUploaded);
setErrors(validate.errors?.[0]);
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
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) {
setFile(fileUploaded);
} else {
setFile(null);
}
}, []);
const [errors, setErrors] = useState('');
const handleSubmit = useCallback(async () => {
setSubmitting(true);
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
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) {
setSubmitting(true);
setErrors('');
try {
const options = {
onlyRundown: overrideOptionRef.current?.checked || false,
};
await uploadData(file, setProgress, options);
if (isOntimeFile(file)) {
// TODO: we would also like to have preview for ontime project files
const options = ontimeFileOptions.current;
await handleOntimeFile(file, options);
doClose = true;
} else if (isExcelFile(file)) {
const options = excelFileOptions.current;
await handleExcelFile(file, options);
await invalidateAllCaches();
}
} catch (error) {
emitError(`Failed uploading file: ${error}`);
const message = maybeAxiosError(error);
setErrors(`Failed uploading file ${message}`);
} finally {
await queryClient.invalidateQueries(RUNDOWN_TABLE);
setSuccess(true);
setSubmitting(false);
if (doClose) {
handleClose();
}
}
}
setSubmitting(false);
}, [emitError, file, queryClient]);
const handleClick = () => {
fileInputRef.current?.click();
};
const clearFile = () => {
setFile(null);
// when we upload excel, we populate state with preview data
async function handleExcelFile(file: File, options: ExcelImportMap) {
const response = await postPreviewExcel(file, setProgress, options);
if (response.status === 200) {
setRundown(response.data.rundown);
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 = () => {
clearFile();
setSuccess(false);
setErrors(undefined);
setProgress(0);
clear();
setRundown([]);
setUserFields(userFieldsPlaceholder);
setProject(projectDataPlaceholder);
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 (
<Modal
@@ -101,66 +177,51 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-small'
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json, .xlsx'
data-testid='file-input'
/>
<div className={style.uploadArea} onClick={handleClick}>
Click to upload Ontime project file
</div>
{file && (
<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>
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
{uploadStep === 'upload' ? (
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
</>
) : (
<PreviewExcel
rundown={rundown ?? []}
project={project ?? projectDataPlaceholder}
userFields={userFields ?? userFieldsPlaceholder}
/>
)}
{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>
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
Cancel
</Button>
<Button
onClick={handleSubmit}
isLoading={isSubmitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Import
</Button>
<ModalFooter>
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
<div className={`${style.buttonSection} ${style.pad}`}>
<Button
onClick={handleGoBack}
isDisabled={disableGoBack || submitting}
variant='ontime-ghost-on-light'
size='sm'
>
Go Back
</Button>
<Button
onClick={handleSubmit}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
{submitText}
</Button>
</div>
</ModalFooter>
</ModalContent>
</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: {
fontWeight: 400,
letterSpacing: '0.3px',
padding: '16px 24px',
fontSize: '20px',
padding: '1rem 1.5rem',
fontSize: '1.25rem',
color: '#202020', // $gray-50
},
dialog: {
@@ -20,17 +20,29 @@ export const ontimeModal = {
color: '#202020', // $gray-50
},
footer: {
padding: '8px',
padding: '0.5rem',
},
};
export const ontimeSmallModal = {
...ontimeModal,
body: {
padding: '16px',
fontSize: '14px',
padding: '1rem',
fontSize: 'calc(1rem - 2px)',
},
dialog: {
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 { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal, ontimeSmallModal } from './ontimeModal';
import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal';
import { ontimeProgressGray } from './OntimeProgress';
import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
@@ -79,6 +80,12 @@ const theme = extendTheme({
variants: {
ontime: { ...ontimeModal },
'ontime-small': { ...ontimeSmallModal },
'ontime-upload': { ...ontimeUploadModal },
},
},
Progress: {
variants: {
'ontime-on-light': { ...ontimeProgressGray },
},
},
Radio: {
+1 -1
View File
@@ -15,7 +15,7 @@
"lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1",
"node-osc": "^8.0.10",
"node-xlsx": "^0.21.0",
"node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*",
"passport": "^0.6.0",
"passport-local": "~1.0.0",
@@ -2,7 +2,16 @@
* Class Event Provider is a mediator for handling the local db
* 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 { safeMerge } from './DataProvider.utils.js';
@@ -45,7 +54,7 @@ export class DataProvider {
return data.settings;
}
static async setSettings(newData) {
static async setSettings(newData: Settings) {
data.settings = { ...newData };
await this.persist();
}
@@ -58,7 +67,7 @@ export class DataProvider {
return data.aliases;
}
static async setAliases(newData) {
static async setAliases(newData: Alias[]) {
data.aliases = newData;
await this.persist();
}
@@ -76,12 +85,12 @@ export class DataProvider {
await this.persist();
}
static async setUserFields(newData) {
static async setUserFields(newData: UserFields) {
data.userFields = { ...newData };
await this.persist();
}
static async setOsc(newData) {
static async setOsc(newData: OSCSettings) {
data.osc = { ...newData };
await this.persist();
}
@@ -95,7 +104,7 @@ export class DataProvider {
await db.write();
}
static async mergeIntoData(newData) {
static async mergeIntoData(newData: Partial<DatabaseModel>) {
const mergedData = safeMerge(data, newData);
data.project = mergedData.project;
data.settings = mergedData.settings;
@@ -1,10 +1,12 @@
import { DatabaseModel } from 'ontime-types';
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
*/
export function safeMerge(existing, newData) {
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
return {
...existing,
rundown: rundown ?? existing.rundown,
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
: {}),
},
},
http: { ...existing.http, ...http },
};
}
@@ -42,11 +42,6 @@ describe('safeMerge', () => {
onFinish: [],
},
},
http: {
enabled: true,
user: null,
pwd: null,
},
};
it('returns existing data if new data is not provided', () => {
@@ -188,19 +183,6 @@ describe('safeMerge', () => {
onFinish: [],
},
},
http: {
user: null,
pwd: null,
messages: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
enabled: true,
},
};
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 fs from 'fs';
@@ -7,13 +7,15 @@ import { networkInterfaces } from 'os';
import { fileHandler } from '../utils/parser.js';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
import { mergeObject } from '../utils/parserUtils.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.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'
// 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 req
* @param res
* @param [options]
* @returns {Promise<void>}
*/
const uploadAndParse = async (file, req, res, options) => {
if (!fs.existsSync(file)) {
res.status(500).send({ message: 'Upload failed' });
return;
}
const parseAndApply = async (file, _req, res, options) => {
const result = await parseFile(file, _req, res, options);
try {
const result = await fileHandler(file);
PlaybackService.stop();
if ('error' in result && result.error) {
res.status(400).send({ message: result.message });
} else if ('data' in result && result.message === 'success') {
PlaybackService.stop();
// explicitly write objects
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}` });
const newRundown = result.rundown || [];
if (options?.onlyRundown === 'true') {
await DataProvider.setRundown(newRundown);
} else {
await DataProvider.mergeIntoData(result);
}
notifyChanges({ timer: true, external: true, reset: true });
};
/**
@@ -169,7 +170,7 @@ export const postUserFields = async (req, res) => {
}
try {
const persistedData = DataProvider.getUserFields();
const newData = mergeObject(persistedData, req.body);
const newData = deepmerge(persistedData, req.body);
await DataProvider.setUserFields(newData);
res.status(200).send(newData);
} catch (error) {
@@ -319,8 +320,38 @@ export const postOSC = async (req, res) => {
}
};
// Create controller for POST request to '/ontime/db'
// Returns -
export async function patchPartialProjectFile(req, res) {
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) => {
if (!req.file) {
res.status(400).send({ message: 'File not found' });
@@ -328,10 +359,39 @@ export const dbUpload = async (req, res) => {
}
const options = req.query;
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) => {
try {
const newProjectData: ProjectData = {
@@ -118,3 +118,18 @@ export const validateOscSubscription = [
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;
}
return parseJson(adapterToUse.data, true);
return parseJson(adapterToUse.data);
};
/**
+9
View File
@@ -9,6 +9,7 @@ import {
getSettings,
getUserFields,
getViewSettings,
patchPartialProjectFile,
poll,
postAliases,
postNew,
@@ -17,12 +18,14 @@ import {
postSettings,
postUserFields,
postViewSettings,
previewExcel,
} from '../controllers/ontimeController.js';
import {
validateAliases,
validateOSC,
validateOscSubscription,
validatePatchProjectFile,
validateSettings,
validateUserFields,
viewValidator,
@@ -40,6 +43,12 @@ router.get('/db', dbDownload);
// create route between controller and '/ontime/db' endpoint
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
router.get('/settings', getSettings);
@@ -35,7 +35,6 @@ import { clock } from '../Clock.js';
*/
export function forceReset() {
eventLoader.reset();
sendRefetch();
runtimeCacheStore.invalidate(delayedRundownCacheKey);
}
@@ -192,15 +191,11 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
// notify timer service of changed events
updateTimer([id]);
notifyChanges({ timer: [id], external: true });
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
return newEvent;
}
@@ -211,11 +206,7 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
const newEvent = await cachedEdit(eventData.id, eventData);
// notify timer service of changed events
updateTimer([newEvent.id]);
// advice socket subscribers of change
sendRefetch();
notifyChanges({ timer: [newEvent.id], external: true });
return newEvent;
}
@@ -228,14 +219,9 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
export async function deleteEvent(eventId) {
await cachedDelete(eventId);
// notify timer service of changed events
updateTimer([eventId]);
notifyChanges({ timer: [eventId], external: true });
// notify event loader that rundown size has changed
updateChangeNumEvents();
// advice socket subscribers of change
sendRefetch();
}
/**
@@ -245,9 +231,7 @@ export async function deleteEvent(eventId) {
export async function deleteAllEvents() {
await cachedClear();
// notify timer service of changed events
updateTimer();
forceReset();
notifyChanges({ timer: true, external: true, reset: true });
}
/**
@@ -260,22 +244,15 @@ export async function deleteAllEvents() {
export async function reorderEvent(eventId: string, from: number, to: number) {
const reorderedItem = await cachedReorder(eventId, from, to);
// notify timer service of changed events
updateTimer();
notifyChanges({ timer: true, external: true });
// advice socket subscribers of change
sendRefetch();
return reorderedItem;
}
export async function applyDelay(eventId: string) {
await cachedApplyDelay(eventId);
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
notifyChanges({ timer: true, external: true });
}
/**
@@ -287,11 +264,7 @@ export async function applyDelay(eventId: string) {
export async function swapEvents(from: string, to: string) {
await cachedSwap(from, to);
// notify timer service of changed events
updateTimer();
// advice socket subscribers of change
sendRefetch();
notifyChanges({ timer: true, external: true });
}
/**
@@ -301,3 +274,26 @@ export async function swapEvents(from: string, to: string) {
function updateChangeNumEvents() {
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 { EndAction, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js';
@@ -525,7 +525,7 @@ describe('test event validator', () => {
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
expect(typeof validated.timeEnd).toEqual('number');
expect(validated.timeEnd).toEqual(0);
expect(validated.timeEnd).toEqual(2);
});
it('handles bad objects', () => {
@@ -579,24 +579,24 @@ describe('test parseExcel function', () => {
[
'Time Start',
'Time End',
'Event Title',
'Presenter Name',
'Event Subtitle',
'Title',
'Presenter',
'Subtitle',
'End Action',
'Timer type',
'Is Public? (x)',
'Skip? (x)',
'Public',
'Skip',
'Notes',
'User0:test0',
'User1:test1',
'User2:test2',
'User3:test3',
'User4:test4',
'User5:test5',
'User6:test6',
'user7:test7',
'user8:test8',
'user9:test9',
'test0',
'test1',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'Colour',
'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 = {
title: 'Test Event',
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.rundown).toBeDefined();
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
@@ -835,7 +848,16 @@ describe('test views import', () => {
app: 'ontime',
version: 2,
},
viewSettings: {},
viewSettings: {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
notAthing: true,
},
views: {
overrideStyles: true,
},
@@ -849,7 +871,7 @@ describe('test views import', () => {
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData, false);
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
@@ -861,16 +883,7 @@ describe('test views import', () => {
version: 2,
},
};
const expectedParsedViewSettings = {
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
warningThreshold: 120000,
dangerColor: '#ED3333',
dangerThreshold: 60000,
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData, true);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
const parsed = parseViewSettings(testData);
expect(parsed).toStrictEqual({});
});
});
@@ -45,7 +45,7 @@ describe('mergeObject()', () => {
third: '',
});
});
test.skip('it only merges fields of the first object', () => {
test('it only merges fields of the first object', () => {
const a = {
first: 'yes',
second: 'yes',
@@ -64,6 +64,35 @@ describe('mergeObject()', () => {
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()', () => {
+178 -179
View File
@@ -1,18 +1,27 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck -- not ready to fully type
import fs from 'fs';
import xlsx from 'node-xlsx';
import { generateId, calculateDuration } from 'ontime-utils';
import {
generateId,
isExcelImportMap,
type ExcelImportMap,
defaultExcelImportMap,
validateEndAction,
validateTimerType,
type ExcelImportOptions,
validateTimes,
} from 'ontime-utils';
import {
DatabaseModel,
EndAction,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimerType,
ProjectData,
UserFields,
EndAction,
TimerType,
} from 'ontime-types';
import fs from 'fs';
import xlsx from 'node-xlsx';
import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js';
@@ -33,27 +42,55 @@ export const JSON_MIME = 'application/json';
/**
* @description Excel array parser
* @param {array} excelData - array with excel sheet
* @param {ExcelImportOptions} options - an object that contains the import map
* @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> = {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
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 = [];
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
// title stuff: strings
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let presenterIndex: number | null = null;
let subtitleIndex: number | null = null;
let isPublicIndex: number | null = null;
let skipIndex: number | null = null;
let notesIndex: 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 user1Index: number | null = null;
let user2Index: number | null = null;
@@ -64,13 +101,11 @@ export const parseExcel = async (excelData) => {
let user7Index: number | null = null;
let user8Index: number | null = null;
let user9Index: number | null = null;
let endActionIndex: number | null = null;
let timerTypeIndex: number | null = null;
excelData
.filter((e) => e.length > 0)
.forEach((row) => {
// project data imports are on the column to the right
// these fields contain the data to its right
let projectTitleNext = false;
let projectDescriptionNext = false;
let publicUrlNext = false;
@@ -81,29 +116,31 @@ export const parseExcel = async (excelData) => {
const event: Partial<OntimeEvent> = {};
row.forEach((column, j) => {
// check flags
// 1. we check if we have set a flag for a known field
if (projectTitleNext) {
projectData.title = column;
projectData.title = makeString(column, '');
projectTitleNext = false;
} else if (projectDescriptionNext) {
projectData.description = column;
projectData.description = makeString(column, '');
projectDescriptionNext = false;
} else if (publicUrlNext) {
projectData.publicUrl = column;
projectData.publicUrl = makeString(column, '');
publicUrlNext = false;
} else if (publicInfoNext) {
projectData.publicInfo = column;
projectData.publicInfo = makeString(column, '');
publicInfoNext = false;
} else if (backstageUrlNext) {
projectData.backstageUrl = column;
projectData.backstageUrl = makeString(column, '');
backstageUrlNext = false;
} else if (backstageInfoNext) {
projectData.backstageInfo = column;
projectData.backstageInfo = makeString(column, '');
backstageInfoNext = false;
} else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column);
} else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) {
event.duration = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = makeString(column, '');
} else if (j === cueIndex) {
@@ -119,155 +156,130 @@ export const parseExcel = async (excelData) => {
} else if (j === notesIndex) {
event.note = makeString(column, '');
} else if (j === endActionIndex) {
if (column === '') {
event.endAction = EndAction.None;
} else {
event.endAction = column;
}
event.endAction = validateEndAction(column);
} else if (j === timerTypeIndex) {
if (column === '') {
event.timerType = TimerType.CountDown;
} else {
event.timerType = column;
}
event.timerType = validateTimerType(column);
} else if (j === colourIndex) {
event.colour = column;
event.colour = makeString(column, '');
} else if (j === user0Index) {
event.user0 = column;
event.user0 = makeString(column, '');
} else if (j === user1Index) {
event.user1 = column;
event.user1 = makeString(column, '');
} else if (j === user2Index) {
event.user2 = column;
event.user2 = makeString(column, '');
} else if (j === user3Index) {
event.user3 = column;
event.user3 = makeString(column, '');
} else if (j === user4Index) {
event.user4 = column;
event.user4 = makeString(column, '');
} else if (j === user5Index) {
event.user5 = column;
event.user5 = makeString(column, '');
} else if (j === user6Index) {
event.user6 = column;
event.user6 = makeString(column, '');
} else if (j === user7Index) {
event.user7 = column;
event.user7 = makeString(column, '');
} else if (j === user8Index) {
event.user8 = column;
event.user8 = makeString(column, '');
} else if (j === user9Index) {
event.user9 = column;
event.user9 = makeString(column, '');
} else {
// 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') {
const col = column.toLowerCase();
// look for keywords
// need to make sure it is a string first
switch (col) {
case 'project name':
case importMap.projectName:
projectTitleNext = true;
break;
case 'project description':
case importMap.projectDescription:
projectDescriptionNext = true;
break;
case 'public url':
case importMap.publicUrl:
publicUrlNext = true;
break;
case 'public info':
case importMap.publicInfo:
publicInfoNext = true;
break;
case 'backstage url':
case importMap.backstageUrl:
backstageUrlNext = true;
break;
case 'backstage info':
case importMap.backstageInfo:
backstageInfoNext = true;
break;
case 'time start':
case 'start':
case importMap.timeStart:
timeStartIndex = j;
break;
case 'time end':
case 'end':
case 'finish':
case importMap.timeEnd:
timeEndIndex = j;
break;
case 'cue':
case 'page':
case importMap.duration:
durationIndex = j;
break;
case importMap.cue:
cueIndex = j;
break;
case 'event title':
case 'title':
case importMap.title:
titleIndex = j;
break;
case 'presenter name':
case 'speaker':
case 'presenter':
case importMap.presenter:
presenterIndex = j;
break;
case 'event subtitle':
case 'subtitle':
case importMap.subtitle:
subtitleIndex = j;
break;
case 'is public? (x)':
case 'is public':
case 'public':
case importMap.isPublic:
isPublicIndex = j;
break;
case 'skip? (x)':
case 'skip?':
case 'skip':
case importMap.skip:
skipIndex = j;
break;
case 'note':
case 'notes':
case importMap.note:
notesIndex = j;
break;
case 'colour':
case 'color':
case importMap.colour:
colourIndex = j;
break;
case 'end action':
case importMap.endAction:
endActionIndex = j;
break;
case 'timer type':
case importMap.timerType:
timerTypeIndex = j;
break;
default:
// look for user defined
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;
}
}
}
case importMap.user0:
user0Index = j;
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 any data was found, push to array
// take care of it in the next step
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
}
});
return {
rundown,
project: projectData,
@@ -286,17 +298,16 @@ export const parseExcel = async (excelData) => {
app: 'ontime',
version: 2,
},
userFields: { ...dbModel.userFields, ...customUserFields },
userFields: customUserFields,
};
};
/**
* @description JSON parser function for v1 of data system
* @param {object} jsonData - json data JSON object to be parsed
* @param {boolean} [enforce=false] - flag, tells to create an object anyway
* @description JSON parser function for ontime project file
* @param {object} jsonData - project file to be parsed
* @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') {
return null;
}
@@ -307,17 +318,17 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
// parse Events
returnData.rundown = parseRundown(jsonData);
// parse Event
returnData.project = parseProject(jsonData, enforce);
returnData.project = parseProject(jsonData) ?? dbModel.project;
// Settings handled partially
returnData.settings = parseSettings(jsonData, enforce);
returnData.settings = parseSettings(jsonData) ?? dbModel.settings;
// View settings handled partially
returnData.viewSettings = parseViewSettings(jsonData, enforce);
returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings;
// Import Aliases if any
returnData.aliases = parseAliases(jsonData);
// Import user fields if any
returnData.userFields = parseUserFields(jsonData);
// Import OSC settings if any
returnData.osc = parseOsc(jsonData, enforce);
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
// Import HTTP settings if any
// returnData.http = parseHttp(jsonData, enforce);
@@ -344,19 +355,19 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
const e = eventArgs;
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 = {
...d,
title: makeString(e.title, d.title),
subtitle: makeString(e.subtitle, d.subtitle),
presenter: makeString(e.presenter, d.presenter),
timeStart: start,
timeEnd: end,
endAction: makeString(e.endAction, d.endAction),
timerType: makeString(e.timerType, d.timerType),
duration: calculateDuration(start, end),
timeStart,
timeEnd,
duration,
endAction: validateEndAction(e.endAction, EndAction.None),
timerType: validateTimerType(e.timerType, TimerType.CountDown),
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
note: makeString(e.note, d.note),
@@ -371,8 +382,8 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
user8: makeString(e.user8, d.user8),
user9: makeString(e.user9, d.user9),
colour: makeString(e.colour, d.colour),
id,
cue: makeString(e.cue, cueFallback),
id,
type: 'event',
};
}
@@ -380,68 +391,56 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
return event;
};
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' };
type ResponseError = { error: true; message: string };
type ResponseOK = {
data: Partial<DatabaseModel>;
};
/**
* @description Middleware function that checks file type and calls relevant parser
* @param {string} file - reference to file
* @param options - import options
* @return {object} - parse result message
*/
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
let res: Partial<ResponseOK | ResponseError> = {};
export const fileHandler = async (file: string, options: ExcelImportOptions): Promise<Partial<ResponseOK>> => {
const res: Partial<ResponseOK> = {};
// check which file type are we dealing with
if (file.endsWith('.xlsx')) {
try {
const excelData = xlsx
.parse(file, { cellDates: true })
.find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule');
// we need to check that the options are applicable
if (!isExcelImportMap(options)) {
throw new Error('Got incorrect options to excel import', JSON.parse(options));
}
// we only look at worksheets called ontime or event schedule
if (excelData?.data) {
const dataFromExcel = await parseExcel(excelData.data);
res.data = {};
res.data.rundown = parseRundown(dataFromExcel);
res.data.project = parseProject(dataFromExcel, true);
res.data.userFields = parseUserFields(dataFromExcel);
res.message = 'success';
} else {
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
res = {
error: true,
message: errorMessage,
};
}
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
const excelData = xlsx
.parse(file, { cellDates: true })
.find(({ name }) => name.toLowerCase() === options.worksheet);
if (excelData?.data) {
const dataFromExcel = parseExcel(excelData.data, options);
// we run the parsed data through an extra step to ensure the objects shape
res.data = {};
res.data.rundown = parseRundown(dataFromExcel);
res.data.project = parseProject(dataFromExcel);
res.data.userFields = parseUserFields(dataFromExcel);
return res;
} else {
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
}
}
if (file.endsWith('.json')) {
// if json check version
const rawdata = fs.readFileSync(file);
const rawdata = fs.readFileSync(file).toString();
let uploadedJson = null;
try {
uploadedJson = JSON.parse(rawdata);
} catch (error) {
return { error: true, message: 'Error parsing JSON file' };
uploadedJson = JSON.parse(rawdata);
if (uploadedJson.settings.version !== 2) {
throw new Error(`Project version unknown ${uploadedJson.settings.version}`);
}
res.data = await parseJson(uploadedJson);
if (uploadedJson.settings.version === 2) {
try {
res.data = await parseJson(uploadedJson);
res.message = 'success';
} catch (error) {
res = { error: true, message: `Error parsing file: ${error}` };
}
} else {
res = { error: true, message: 'Error parsing file, version unknown' };
}
// delete file
await deleteFile(file);
return res;
}
// delete file
await deleteFile(file);
return res;
};
+7 -44
View File
@@ -1,7 +1,6 @@
import { generateId } from 'ontime-utils';
import {
Alias,
EndAction,
OntimeRundown,
OSCSettings,
OscSubscription,
@@ -9,7 +8,6 @@ import {
ProjectData,
Settings,
TimerLifeCycle,
TimerType,
UserFields,
ViewSettings,
} from 'ontime-types';
@@ -45,18 +43,6 @@ export const parseRundown = (data): OntimeRundown => {
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') {
eventIndex += 1;
const event = validateEvent(e, eventIndex.toString());
@@ -88,10 +74,9 @@ export const parseRundown = (data): OntimeRundown => {
/**
* Parse event portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseProject = (data, enforce): ProjectData => {
export const parseProject = (data): 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
// TODO: Remove eventually
@@ -109,9 +94,6 @@ export const parseProject = (data, enforce): ProjectData => {
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
};
} else if (enforce) {
newProjectData = { ...dbModel.project };
console.log('Created project object in db');
}
return newProjectData as ProjectData;
};
@@ -119,10 +101,9 @@ export const parseProject = (data, enforce): ProjectData => {
/**
* Parse settings portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseSettings = (data, enforce): Settings => {
export const parseSettings = (data): Settings => {
let newSettings: Partial<Settings> = {};
if ('settings' in data) {
console.log('Found settings definition, importing...');
@@ -146,9 +127,6 @@ export const parseSettings = (data, enforce): Settings => {
...settings,
};
}
} else if (enforce) {
newSettings = dbModel.settings;
console.log('Created settings object in db');
}
return newSettings as Settings;
};
@@ -156,10 +134,9 @@ export const parseSettings = (data, enforce): Settings => {
/**
* Parse settings portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseViewSettings = (data, enforce): ViewSettings => {
export const parseViewSettings = (data): ViewSettings => {
let newViews: Partial<ViewSettings> = {};
if ('viewSettings' in data) {
console.log('Found view definition, importing...');
@@ -175,13 +152,7 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
};
// write to db
newViews = {
...viewSettings,
};
} else if (enforce) {
newViews = dbModel.viewSettings;
console.log('Created viewSettings object in db');
newViews = { ...viewSettings };
}
return newViews as ViewSettings;
};
@@ -224,16 +195,11 @@ export const validateOscObject = (data: OscSubscription): boolean => {
/**
* Parse osc portion of an entry
*/
export const parseOsc = (
data: {
osc?: Partial<OSCSettings>;
},
enforce: boolean,
): OSCSettings | Record<string, never> => {
export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
const loadedConfig = data?.osc || {};
const loadedConfig = data.osc || {};
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions
: dbModel.osc.subscriptions;
@@ -246,10 +212,7 @@ export const parseOsc = (
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
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 { deepmerge } from 'ontime-utils';
/**
* @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
* @param {object} a
* @param {object} b
* @param {object} a - any object
* @param {object} b - a potential partial object of same time as a
*/
export const mergeObject = (a, b) => {
const merged = {};
Object.keys({ ...a, ...b }).map((key) => {
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key];
});
export function mergeObject<T extends Record<string, any>>(a: T, b: Partial<Record<keyof T, any>>): T {
const merged = { ...a };
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;
};
}
/**
* @description Removes undefined
+11 -7
View File
@@ -89,14 +89,18 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
* @returns {number} - time in milliseconds
*/
export const parseExcelDate = (excelDate: string): number => {
// attempt converting to date object
const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date.getTime())) {
return dateToMillis(date);
} else if (isTimeString(excelDate)) {
return forgivingStringToMillis(excelDate);
export const parseExcelDate = (excelDate: unknown): number => {
if (excelDate instanceof Date) {
return dateToMillis(excelDate);
} else if (typeof excelDate === 'string') {
const date = new Date(excelDate);
if (date instanceof Date && !isNaN(date.getTime())) {
return dateToMillis(date);
} else if (isTimeString(excelDate)) {
return forgivingStringToMillis(excelDate);
}
}
return 0;
};
+18 -1
View File
@@ -1,12 +1,13 @@
// runtime utils
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.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
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.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';
// format utils
@@ -18,5 +19,21 @@ export { millisToString } from './src/date-utils/millisToString.js';
// time utils
export { dayInMs, mts } from './src/timeConstants.js';
// helpers from externals
export { deepmerge } from './src/externals/deepmerge.js';
// generic utilities
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"
},
"dependencies": {
"deepmerge-ts": "^5.1.0",
"luxon": "^3.3.0",
"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 { getNextEvent, getPreviousEvent } from './rundownUtils';
import { calculateDuration } from './rundownUtils.js';
describe('getNextEvent()', () => {
it('returns the next event of type event', () => {
@@ -71,25 +70,3 @@ describe('getPreviousEvent()', () => {
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 { dayInMs } from '../timeConstants.js';
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
@@ -113,20 +111,6 @@ export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: strin
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
* @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 };
}
+32 -89
View File
@@ -188,7 +188,7 @@ importers:
lowdb: ^5.0.5
multer: ^1.4.5-lts.1
node-osc: ^8.0.10
node-xlsx: ^0.21.0
node-xlsx: ^0.23.0
nodemon: ^2.0.20
ontime-types: workspace:*
ontime-utils: workspace:*
@@ -211,7 +211,7 @@ importers:
lowdb: 5.0.5
multer: 1.4.5-lts.1
node-osc: 8.0.10
node-xlsx: 0.21.0
node-xlsx: 0.23.0
ontime-utils: link:../../packages/utils
passport: 0.6.0
passport-local: 1.0.0
@@ -251,6 +251,7 @@ importers:
'@types/luxon': ^3.2.0
'@typescript-eslint/eslint-plugin': ^5.48.1
'@typescript-eslint/parser': ^5.48.1
deepmerge-ts: ^5.1.0
eslint: ^8.31.0
eslint-config-prettier: ^8.6.0
eslint-plugin-prettier: ^4.2.1
@@ -262,6 +263,7 @@ importers:
typescript: ^4.9.4
vitest: ^0.30.1
dependencies:
deepmerge-ts: 5.1.0
luxon: 3.3.0
nanoid: 4.0.1
devDependencies:
@@ -3289,20 +3291,6 @@ packages:
hasBin: 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:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
@@ -3751,14 +3739,6 @@ packages:
resolution: {integrity: sha512-ecER9xgJQVMqcrxThKptsW0pPxSae8R2RB87LNa+ivW9ppNWRHEplXcDzkCOP4LYWGj8hunXLqaiC41iBATNyg==}
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:
resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==}
engines: {node: '>=4'}
@@ -3812,7 +3792,7 @@ packages:
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/chownr/2.0.0:
@@ -3854,11 +3834,6 @@ packages:
mimic-response: 1.0.1
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:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
@@ -4024,12 +3999,6 @@ packages:
path-type: 4.0.0
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:
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
requiresBuild: true
@@ -4189,6 +4158,11 @@ packages:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
dev: true
/deepmerge-ts/5.1.0:
resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==}
engines: {node: '>=16.0.0'}
dev: false
/deepmerge/4.3.0:
resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==}
engines: {node: '>=0.10.0'}
@@ -4860,11 +4834,6 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/exit-on-epipe/1.0.1:
resolution: {integrity: sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==}
engines: {node: '>=0.8'}
dev: false
/expect/29.3.1:
resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5102,11 +5071,6 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/frac/1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
dev: false
/framer-motion/10.11.2_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-IrwuC9regNOU99JoM/Z62CAMA3awGV6AcF7e3bcgXk/ZoNlGSt5aVq0J7UAwtLmCkwVlRvBkiMnvv2mZ1GW2pg==}
peerDependencies:
@@ -5183,6 +5147,14 @@ packages:
dev: 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:
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
@@ -6287,12 +6259,12 @@ packages:
resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==}
dev: true
/node-xlsx/0.21.0:
resolution: {integrity: sha512-MB+KcNCuRzwjgr17scpKiVTPd4Vbj3V+7QwKpqACGyJzhvC67xCQUbw2vYEIKtNfMfcLxgB2q2kEuRS8rmak9g==}
/node-xlsx/0.23.0:
resolution: {integrity: sha512-r3KaSZSsSrK92rbPXnX/vDdxURmPPik0rjJ3A+Pybzpjyrk4G6WyGfj8JIz5dMMEpCmWVpmO4qoVPBxnpLv/8Q==}
engines: {node: '>=10.0.0'}
hasBin: true
dependencies:
xlsx: 0.17.5
xlsx: '@cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz'
dev: false
/nodemon/2.0.20:
@@ -6648,12 +6620,6 @@ packages:
react-is: 18.2.0
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:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
dev: false
@@ -7056,7 +7022,7 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/run-parallel/1.2.0:
@@ -7295,13 +7261,6 @@ packages:
dev: 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:
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
engines: {node: '>=10'}
@@ -8029,7 +7988,7 @@ packages:
postcss: 8.4.21
rollup: 3.20.7
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/vite/4.3.1_c45e4f2cmsrvgdqihjqb2lwfbq:
@@ -8341,21 +8300,11 @@ packages:
stackback: 0.0.2
dev: true
/wmf/1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
dev: false
/word-wrap/1.2.3:
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
engines: {node: '>=0.10.0'}
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:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -8395,20 +8344,6 @@ packages:
optional: true
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:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
@@ -8500,4 +8435,12 @@ packages:
dependencies:
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