diff --git a/apps/client/src/common/api/apiUtils.ts b/apps/client/src/common/api/apiUtils.ts index 1c15707fc..4042761bb 100644 --- a/apps/client/src/common/api/apiUtils.ts +++ b/apps/client/src/common/api/apiUtils.ts @@ -2,18 +2,33 @@ 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}`; + if (typeof error !== 'string') { + return JSON.stringify(error); + } + return error; } +} + +export function logAxiosError(prepend: string, error: unknown) { + const message = `${prepend}: ${maybeAxiosError(error)}`; addLog({ id: generateId(), @@ -23,3 +38,17 @@ export function logAxiosError(prepend: string, error: unknown) { text: message, }); } + +/** + * Utility function invalidates react-query caches + */ +export async function invalidateAllCaches() { + await ontimeQueryClient.invalidateQueries(['project']); + await ontimeQueryClient.invalidateQueries(['aliases']); + await ontimeQueryClient.invalidateQueries(['userFields']); + await ontimeQueryClient.invalidateQueries(['rundown']); + await ontimeQueryClient.invalidateQueries(['appinfo']); + await ontimeQueryClient.invalidateQueries(['oscSettings']); + await ontimeQueryClient.invalidateQueries(['appSettings']); + await ontimeQueryClient.invalidateQueries(['viewSettings']); +} diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 593f7d35d..1cb33ea23 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -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'; @@ -123,17 +134,25 @@ export const downloadRundown = () => { return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' }); }; +// 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, +) => { const formData = new FormData(); formData.append('userFile', file); - const onlyRundown = options?.onlyRundown || 'false'; + + const onlyRundown = Boolean(options?.onlyRundown); + await axios .post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, { headers: { @@ -147,6 +166,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) { + 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 = 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; diff --git a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx index bbafefec5..fd81d7aca 100644 --- a/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx +++ b/apps/client/src/common/components/input/colour-input/SwatchSelect.tsx @@ -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 = [ diff --git a/apps/client/src/features/AliasWrapper.tsx b/apps/client/src/features/AliasWrapper.tsx index f6808edab..9f0422ea4 100644 --- a/apps/client/src/features/AliasWrapper.tsx +++ b/apps/client/src/features/AliasWrapper.tsx @@ -22,7 +22,7 @@ const withAlias =

(Component: ComponentType

) => { } }, [data, searchParams, navigate, location]); - return ; + return ; }; }; diff --git a/apps/client/src/features/event-editor/composite/CountedTextArea.tsx b/apps/client/src/features/event-editor/composite/CountedTextArea.tsx index 225e1e65a..10de23bdf 100644 --- a/apps/client/src/features/event-editor/composite/CountedTextArea.tsx +++ b/apps/client/src/features/event-editor/composite/CountedTextArea.tsx @@ -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) { diff --git a/apps/client/src/features/modals/Modal.module.scss b/apps/client/src/features/modals/Modal.module.scss index 10a97436b..a5b8ea1a0 100644 --- a/apps/client/src/features/modals/Modal.module.scss +++ b/apps/client/src/features/modals/Modal.module.scss @@ -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; } diff --git a/apps/client/src/features/modals/upload-modal/UploadFile.tsx b/apps/client/src/features/modals/upload-modal/UploadFile.tsx new file mode 100644 index 000000000..3497d3fbe --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/UploadFile.tsx @@ -0,0 +1,66 @@ +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(null); + + const { file, setFile, progress } = useUploadModalContextStore(); + + const [errors, setErrors] = useState(''); + + const clearFile = () => { + setFile(null); + setErrors(''); + }; + + const handleFile = (event: ChangeEvent) => { + 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); + } else { + setErrors('An unexpected error occurred while validating the file.'); + } + setFile(null); + } + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + return ( + <> + + {!file && ( +

+ Click to select Ontime project or xlsx rundown +
+ )} + {(file || errors) && } + + ); +} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.module.scss b/apps/client/src/features/modals/upload-modal/UploadModal.module.scss index fdd7d5778..c35c1a31d 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.module.scss +++ b/apps/client/src/features/modals/upload-modal/UploadModal.module.scss @@ -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; +} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.tsx b/apps/client/src/features/modals/upload-modal/UploadModal.tsx index c328551f5..b8ee198d6 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadModal.tsx @@ -1,7 +1,6 @@ -import { ChangeEvent, useCallback, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Button, - Input, Modal, ModalBody, ModalCloseButton, @@ -9,23 +8,34 @@ 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 { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants'; +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 { getPersistedOptions, isExcelFile, isOntimeFile, persistOptions } from './uploadUtils'; import style from './UploadModal.module.scss'; +export type UploadStep = 'import' | 'review'; + interface UploadModalProps { onClose: () => void; isOpen: boolean; @@ -33,64 +43,152 @@ interface UploadModalProps { export default function UploadModal({ onClose, isOpen }: UploadModalProps) { const queryClient = useQueryClient(); - const { emitError } = useEmitLog(); - const [errors, setErrors] = useState(); - const [isSubmitting, setSubmitting] = useState(false); - const [file, setFile] = useState(null); - const [progress, setProgress] = useState(0); - const overrideOptionRef = useRef(null); - const fileInputRef = useRef(null); - const [success, setSuccess] = useState(false); - const handleFile = useCallback((event: ChangeEvent) => { - 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('import'); + const [submitting, setSubmitting] = useState(false); + const [rundown, setRundown] = useState(null); + const [userFields, setUserFields] = useState(null); + const [project, setProject] = useState(null); - if (validate.isValid) { - setFile(fileUploaded); - } else { - setFile(null); + const [errors, setErrors] = useState(''); + + const ontimeFileOptions = useRef>({}); + const excelFileOptions = useRef(defaultExcelImportMap); + + const updateOntimeFileOptions = ( + field: T, + value: ProjectFileImportOptions[T], + ) => { + ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value }; + }; + + const updateExcelFileOptions = (field: T, value: ExcelImportMap[T]) => { + if (excelFileOptions.current[field] !== value) { + excelFileOptions.current = { ...excelFileOptions.current, [field]: value }; + } + }; + + // We want to populate the options with any previous options given by the user + useEffect(() => { + const excelOptions = getPersistedOptions('excel'); + if (excelOptions) { + excelFileOptions.current = excelOptions; } }, []); - const handleSubmit = useCallback(async () => { - setSubmitting(true); + // if the modal re-opens, we want to restart all states + useEffect(() => { + clear(); + setUploadStep('import'); + 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); + await invalidateAllCaches(); + doClose = true; + } else if (isExcelFile(file)) { + const options = excelFileOptions.current; + persistOptions({ optionType: 'excel', options }); + await handleExcelFile(file, options); + } } 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) { + 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('import'); + setErrors(''); + }; + + const isImporting = uploadStep === 'import'; + const isReview = uploadStep === 'review'; + const isExcel = isExcelFile(file); + const isOntime = isOntimeFile(file); + + const handleGoBack = isImporting ? undefined : undoReview; + const handleSubmit = isImporting ? handleUpload : handleFinalise; + const disableSubmit = (isImporting && !file) || (isReview && rundown === null); + const disableGoBack = isImporting; + const submitText = isImporting ? 'Import' : 'Finish'; return ( File import - -
- Click to upload Ontime project file -
- {file && ( -
- - - {file.name} - {`${(file.size / 1024).toFixed(2)}kb - ${file.type}`} - -
+ {isExcel && } + {uploadStep === 'import' ? ( + <> + + {isOntime && } + {isExcel && } + + ) : ( + )} - {errors && ( -
- - {errors} - Please try again - -
- )} -
- Import options - - - -
- - - + +
{errors &&
{errors}
}
+
+ + +
diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewColumn.module.scss b/apps/client/src/features/modals/upload-modal/preview/PreviewColumn.module.scss new file mode 100644 index 000000000..72a783ee4 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/PreviewColumn.module.scss @@ -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; +} diff --git a/apps/client/src/common/utils/entryValidator.ts b/apps/client/src/features/modals/upload-modal/preview/PreviewColumn.tsx similarity index 100% rename from apps/client/src/common/utils/entryValidator.ts rename to apps/client/src/features/modals/upload-modal/preview/PreviewColumn.tsx diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx b/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx new file mode 100644 index 000000000..1e537e77a --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/PreviewExcel.tsx @@ -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 ( +
+
Review Project Data
+ +
+
Review Rundown
+ +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewProjectData.tsx b/apps/client/src/features/modals/upload-modal/preview/PreviewProjectData.tsx new file mode 100644 index 000000000..46eb00d47 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/PreviewProjectData.tsx @@ -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 ( +
+ Title + {project.title} + Description + {project.description} + Public URL + {project.publicUrl} + Public info + {project.publicInfo} + Backstage URL + {project.backstageUrl} + Backstage info + {project.backstageInfo} +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx b/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx new file mode 100644 index 000000000..12f962243 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/PreviewRundown.tsx @@ -0,0 +1,131 @@ +import { Fragment } from 'react'; +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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {rundown.map((event, index) => { + if (!isOntimeEvent(event)) { + return null; + } + const key = event.id; + const colour = event.colour ? getAccessibleColour(event.colour) : {}; + const isPublic = booleanToText(event.isPublic); + const skip = booleanToText(event.skip); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + {event.note && ( + + + + )} + + ); + })} + +
#TypeCueTitleSubtitlePresenterTime StartTime EndDurationIs PublicSkipColourTimer TypeEnd Action + user0 {userFields.user0} + + user1 {userFields.user1} + + user2 {userFields.user2} + + user3 {userFields.user3} + + user4 {userFields.user4} + + user5 {userFields.user5} + + user6 {userFields.user6} + + user7 {userFields.user7} + + user8 {userFields.user8} + + user9 {userFields.user9} +
+ {index + 1} + + Event + {event.cue}{event.title}{event.subtitle}{event.presenter}{millisToString(event.timeStart)}{millisToString(event.timeEnd)}{millisToString(event.duration)}{isPublic && {isPublic}}{skip && {skip}}{event.colour} + {event.timerType} + + {event.endAction} + {event.user0}{event.user1}{event.user2}{event.user3}{event.user4}{event.user5}{event.user6}{event.user7}{event.user8}{event.user9}
+ Note: {event.note} +
+
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss b/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss new file mode 100644 index 000000000..008913718 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/PreviewTable.module.scss @@ -0,0 +1,71 @@ +@use "../../../../theme/_ontimeColours" as *; + +.container { + max-width: 100%; + max-height: max(300px, 30vh); + overflow: scroll; +} + +.rundownPreview { + font-size: calc(1rem - 2px); + overflow-x: scroll; +} + +.header, +.body { + th { + font-weight: 400; + height: unset; + line-height: calc(1rem - 2px); + white-space: nowrap; + padding-left: 0.25rem; + padding-right: 1rem; + } +} + +.header { + position: sticky; + top: 0; + background-color: white; + box-shadow: 0 2px $gray-50; + z-index: 3; + + th { + font-weight: 200; + text-align: left; + } + + tr { + word-wrap: unset; + } +} + +.body { + td { + text-align: left; + vertical-align: top; + padding: 0 0.5em; + } + + .center { + text-align: center; + } + + .nowrap { + white-space: nowrap; + } + + .secondaryRow { + padding: 0.25em 0.25em; + background-color: $gray-50; + } +} + +table tr td:first-child, +table tr th:first-child { + position: sticky; + left: 0; + z-index: 2; + background-color: white; + box-shadow: 1px 0 $gray-50; +} \ No newline at end of file diff --git a/apps/client/src/features/modals/upload-modal/preview/Tag.module.scss b/apps/client/src/features/modals/upload-modal/preview/Tag.module.scss new file mode 100644 index 000000000..9ff41a348 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/Tag.module.scss @@ -0,0 +1,11 @@ +@use "../../../../theme/_ontimeColours" as *; + +.tag { + font-size: calc(1rem - 3px); + letter-spacing: 0.5px; + background-color: $gray-100; + color: $ui-black; + border-radius: 2px; + padding: 0 0.25rem; + white-space: nowrap; +} diff --git a/apps/client/src/features/modals/upload-modal/preview/Tag.tsx b/apps/client/src/features/modals/upload-modal/preview/Tag.tsx new file mode 100644 index 000000000..de1ce550c --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/preview/Tag.tsx @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +import style from './Tag.module.scss'; + +export default function Tag({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss new file mode 100644 index 000000000..86efa6e68 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss @@ -0,0 +1,53 @@ +@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; + } + } +} diff --git a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx new file mode 100644 index 000000000..2c3545f75 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx @@ -0,0 +1,52 @@ +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; + handleClear: () => void; +} + +export default function UploadEntry(props: UploadEntryProps) { + const { file, errors, progress, handleClear } = props; + + if (errors) { + return ( +
+ + + {errors} + Please try again +
+ ); + } + + 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 ( +
+ + + {file.name} + {`${fileSize} - ${fileType}`} + +
+ ); + } + + return null; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx new file mode 100644 index 000000000..72b1c5837 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ExcelFileOptions.tsx @@ -0,0 +1,70 @@ +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; + updateOptions: (field: T, value: ExcelImportMap[T]) => void; +} + +export default function ExcelFileOptions(props: ExcelFileOptionsProps) { + const { optionsRef, updateOptions } = props; + + 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 ( +
+
+ +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss new file mode 100644 index 000000000..0eb49359e --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.module.scss @@ -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%; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx new file mode 100644 index 000000000..1435d0875 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/ImportMapTable.tsx @@ -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 ( + + + + + + + + {fields.map((field) => { + return ( + + + + + ); + })} + +
{title}
+ + + { + handleOnChange(field.title, event.target.value); + }} + /> +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx b/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx new file mode 100644 index 000000000..812bfd2cc --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx @@ -0,0 +1,31 @@ +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>; + updateOptions: (field: T, value: ProjectFileImportOptions[T]) => void; +} + +export default function OntimeFileOptions(props: OntimeFileOptionsProps) { + const { optionsRef, updateOptions } = props; + + return ( +
+ Import options + + { + updateOptions('onlyRundown', e.target.checked); + }} + defaultChecked={Boolean(optionsRef.current.onlyRundown)} + /> + +
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss new file mode 100644 index 000000000..f2f59fc0f --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss @@ -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; +} diff --git a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx new file mode 100644 index 000000000..f3c369dee --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx @@ -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 isImporting = uploadStep === 'import'; + const isReview = uploadStep === 'review'; + + return ( +
+
+ + Import +
+ +
+ {isReview ? : } + Review +
+
+ ); +} diff --git a/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx b/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx new file mode 100644 index 000000000..e17bc0869 --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx @@ -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((set) => ({ + file: null, + setFile: (file: File | null) => set({ file }), + + progress: 0, + setProgress: (progress: number) => set({ progress }), + + clear: () => set({ file: null, progress: 0 }), +})); diff --git a/apps/client/src/features/modals/upload-modal/uploadUtils.ts b/apps/client/src/features/modals/upload-modal/uploadUtils.ts new file mode 100644 index 000000000..1bbeb962d --- /dev/null +++ b/apps/client/src/features/modals/upload-modal/uploadUtils.ts @@ -0,0 +1,64 @@ +import { ExcelImportMap } from 'ontime-utils'; + +import { ProjectFileImportOptions } from '../../../common/api/ontimeApi'; + +/** + * Validates a file according to the app upload contract + * @throws + * @param file + */ +export function validateFile(file: File) { + if (!file) { + throw new Error('No file to upload'); + } + + // Check if file is empty + if (file.size === 0) { + throw new Error('File is empty'); + } + + // 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'); +} + +type PersistedOntimeOptions = { + optionType: 'ontime'; + options: Partial; +}; + +type PersistedExcelOptions = { + optionType: 'excel'; + options: ExcelImportMap; +}; + +export function persistOptions(options: PersistedOntimeOptions | PersistedExcelOptions) { + localStorage.setItem(`ontime-import-options-${options.optionType}`, JSON.stringify(options.options)); +} + +export function getPersistedOptions(optionType: 'excel' | 'ontime') { + const options = localStorage.getItem(`ontime-import-options-${optionType}`); + if (!options) { + return null; + } + return JSON.parse(options); +} diff --git a/apps/client/src/features/modals/upload-modal/utils.ts b/apps/client/src/features/modals/upload-modal/utils.ts deleted file mode 100644 index 940448571..000000000 --- a/apps/client/src/features/modals/upload-modal/utils.ts +++ /dev/null @@ -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; -} diff --git a/apps/client/src/theme/OntimeProgress.ts b/apps/client/src/theme/OntimeProgress.ts new file mode 100644 index 000000000..cb5c7bf2e --- /dev/null +++ b/apps/client/src/theme/OntimeProgress.ts @@ -0,0 +1,8 @@ +export const ontimeProgressGray = { + track: { + background: '#f6f6f6', // $gray-500 + }, + filledTrack: { + background: '#578AF4', // $blue-500 + }, +}; diff --git a/apps/client/src/theme/ontimeModal.ts b/apps/client/src/theme/ontimeModal.ts index bba0429cd..6abae3dd5 100644 --- a/apps/client/src/theme/ontimeModal.ts +++ b/apps/client/src/theme/ontimeModal.ts @@ -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)', + }, +}; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index d1b89a58b..2378a270e 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -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: { diff --git a/apps/server/package.json b/apps/server/package.json index b4ba77d2e..805e1db5f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,7 +15,7 @@ "lowdb": "^5.0.5", "multer": "^1.4.5-lts.1", "node-osc": "^9.0.2", - "node-xlsx": "^0.21.0", + "node-xlsx": "^0.23.0", "ontime-utils": "workspace:*", "passport": "^0.6.0", "passport-local": "~1.0.0", diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index f155ba374..ea5285b9b 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -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) { const mergedData = safeMerge(data, newData); data.project = mergedData.project; data.settings = mergedData.settings; diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index f2ea10d68..eed142912 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -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) { + 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 }, }; } diff --git a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts index 6a6f85a77..e17b019da 100644 --- a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts @@ -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 = { diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 8a35ec09c..76eca8aea 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -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 @@ -44,43 +46,40 @@ export const dbDownload = async (req, res) => { }; /** - * 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} */ -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 +168,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) { @@ -324,8 +323,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 = { + 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, parses and applies the data from a given file + */ export const dbUpload = async (req, res) => { if (!req.file) { res.status(400).send({ message: 'File not found' }); @@ -333,10 +362,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 = { diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 5f67aa4e3..8be1c8bf5 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -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(); + }, +]; diff --git a/apps/server/src/modules/loadDb.ts b/apps/server/src/modules/loadDb.ts index 9de1cccd5..4b831f131 100644 --- a/apps/server/src/modules/loadDb.ts +++ b/apps/server/src/modules/loadDb.ts @@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => { adapterToUse.data = dbModel; } - return parseJson(adapterToUse.data, true); + return parseJson(adapterToUse.data); }; /** diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 8ff789817..67f4fb033 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -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); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 7d1762506..8da7f6642 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -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 | Partial | Partial | Partial { 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', () => { @@ -580,24 +580,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', ], @@ -652,6 +652,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', @@ -707,7 +720,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]); @@ -836,7 +849,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, }, @@ -850,7 +872,7 @@ describe('test views import', () => { endMessage: '', overrideStyles: false, }; - const parsed = parseViewSettings(testData, false); + const parsed = parseViewSettings(testData); expect(parsed).toStrictEqual(expectedParsedViewSettings); }); @@ -862,16 +884,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({}); }); }); diff --git a/apps/server/src/utils/__tests__/parserUtils.test.ts b/apps/server/src/utils/__tests__/parserUtils.test.ts index 0b348a083..a69957328 100644 --- a/apps/server/src/utils/__tests__/parserUtils.test.ts +++ b/apps/server/src/utils/__tests__/parserUtils.test.ts @@ -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()', () => { diff --git a/apps/server/src/utils/fileManagement.js b/apps/server/src/utils/fileManagement.ts similarity index 100% rename from apps/server/src/utils/fileManagement.js rename to apps/server/src/utils/fileManagement.ts diff --git a/apps/server/src/utils/getRandomName.js b/apps/server/src/utils/getRandomName.ts similarity index 100% rename from apps/server/src/utils/getRandomName.js rename to apps/server/src/utils/getRandomName.ts diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 260bab21b..bd2459869 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -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) => { + const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options }; const projectData: Partial = { title: '', description: '', publicUrl: '', + publicInfo: '', backstageUrl: '', + backstageInfo: '', + }; + const customUserFields: Partial = { + 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 = {}; 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; @@ -79,31 +114,68 @@ export const parseExcel = async (excelData) => { let backstageInfoNext = false; const event: Partial = {}; + const handlers = { + [importMap.projectName]: () => (projectTitleNext = true), + [importMap.projectDescription]: () => (projectDescriptionNext = true), + [importMap.publicUrl]: () => (publicUrlNext = true), + [importMap.publicInfo]: () => (publicInfoNext = true), + [importMap.backstageUrl]: () => (backstageUrlNext = true), + [importMap.backstageInfo]: () => (backstageInfoNext = true), + + [importMap.timeStart]: (index: number) => (timeStartIndex = index), + [importMap.timeEnd]: (index: number) => (timeEndIndex = index), + [importMap.duration]: (index: number) => (durationIndex = index), + + [importMap.cue]: (index: number) => (cueIndex = index), + [importMap.title]: (index: number) => (titleIndex = index), + [importMap.presenter]: (index: number) => (presenterIndex = index), + [importMap.subtitle]: (index: number) => (subtitleIndex = index), + [importMap.isPublic]: (index: number) => (isPublicIndex = index), + [importMap.skip]: (index: number) => (skipIndex = index), + [importMap.note]: (index: number) => (notesIndex = index), + [importMap.colour]: (index: number) => (colourIndex = index), + + [importMap.endAction]: (index: number) => (endActionIndex = index), + [importMap.timerType]: (index: number) => (timerTypeIndex = index), + + [importMap.user0]: (index: number) => (user0Index = index), + [importMap.user1]: (index: number) => (user1Index = index), + [importMap.user2]: (index: number) => (user2Index = index), + [importMap.user3]: (index: number) => (user3Index = index), + [importMap.user4]: (index: number) => (user4Index = index), + [importMap.user5]: (index: number) => (user5Index = index), + [importMap.user6]: (index: number) => (user6Index = index), + [importMap.user7]: (index: number) => (user7Index = index), + [importMap.user8]: (index: number) => (user8Index = index), + [importMap.user9]: (index: number) => (user9Index = index), + } as const; 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,166 +191,51 @@ 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': - projectTitleNext = true; - break; - case 'project description': - projectDescriptionNext = true; - break; - case 'public url': - publicUrlNext = true; - break; - case 'public info': - publicInfoNext = true; - break; - case 'backstage url': - backstageUrlNext = true; - break; - case 'backstage info': - backstageInfoNext = true; - break; - case 'time start': - case 'start': - timeStartIndex = j; - break; - case 'time end': - case 'end': - case 'finish': - timeEndIndex = j; - break; - case 'cue': - case 'page': - cueIndex = j; - break; - case 'event title': - case 'title': - titleIndex = j; - break; - case 'presenter name': - case 'speaker': - case 'presenter': - presenterIndex = j; - break; - case 'event subtitle': - case 'subtitle': - subtitleIndex = j; - break; - case 'is public? (x)': - case 'is public': - case 'public': - isPublicIndex = j; - break; - case 'skip? (x)': - case 'skip?': - case 'skip': - skipIndex = j; - break; - case 'note': - case 'notes': - notesIndex = j; - break; - case 'colour': - case 'color': - colourIndex = j; - break; - case 'end action': - endActionIndex = j; - break; - case 'timer type': - 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; - } - } - } - break; + if (handlers[col]) { + handlers[col](j); } + // else. we don't know how to handle this column + // just ignore it } } }); 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 +243,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 => { +export const parseJson = async (jsonData): Promise => { if (!jsonData || typeof jsonData !== 'object') { return null; } @@ -307,17 +263,17 @@ export const parseJson = async (jsonData, enforce = false): Promise, 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 +327,8 @@ export const validateEvent = (eventArgs: Partial, 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 +336,59 @@ export const validateEvent = (eventArgs: Partial, cueFallback: stri return event; }; -type ResponseOK = { data: Partial; message: 'success' }; -type ResponseError = { error: true; message: string }; +type ResponseOK = { + data: Partial; +}; /** * @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 => { - let res: Partial = {}; +export const fileHandler = async (file: string, options: ExcelImportOptions): Promise> => { + const res: Partial = {}; // check which file type are we dealing with if (file.endsWith('.xlsx')) { - try { - const excelData = xlsx - .parse(file, { cellDates: true }) - .find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule'); - - // we only look at worksheets called ontime or event schedule - if (excelData?.data) { - const dataFromExcel = await 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}` }; + // we need to check that the options are applicable + if (!isExcelImportMap(options)) { + throw new Error('Got incorrect options to excel import', JSON.parse(options)); } + + const excelData = xlsx + .parse(file, { cellDates: true }) + .find(({ name }) => name.toLowerCase() === options.worksheet.toLowerCase()); + + if (!excelData?.data) { + throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`); + } + + 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); + if (res.data.rundown.length < 1) { + throw new Error(`Could not find data to import in the worksheet ${options.worksheet}`); + } + res.data.project = parseProject(dataFromExcel); + res.data.userFields = parseUserFields(dataFromExcel); + return res; } 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; }; diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 2bc0e3b75..3282e48f3 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -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 = {}; // 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 = {}; 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 = {}; 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; - }, - enforce: boolean, -): OSCSettings | Record => { +export const parseOsc = (data: { osc?: Partial }): 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 {}; + } }; /** diff --git a/apps/server/src/utils/parserUtils.ts b/apps/server/src/utils/parserUtils.ts index 4a09058c0..7adf2b7f9 100644 --- a/apps/server/src/utils/parserUtils.ts +++ b/apps/server/src/utils/parserUtils.ts @@ -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>(a: T, b: Partial>): 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 diff --git a/apps/server/src/utils/time.ts b/apps/server/src/utils/time.ts index d92aa7488..f3a010436 100644 --- a/apps/server/src/utils/time.ts +++ b/apps/server/src/utils/time.ts @@ -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; }; diff --git a/apps/server/src/utils/url.js b/apps/server/src/utils/url.js deleted file mode 100644 index 9da592e10..000000000 --- a/apps/server/src/utils/url.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @description Cleans given url - * @param {string} url - URL to be checked - * @returns {string} Sanitized url - */ -export const cleanURL = (url) => { - // trim whitespaces - let r = url.trim(); - - // clear any whitespaces - r = r.split(' ').join('%20'); - - // contain only allowed characters - r = r.replace(/([@\s<>[\]{}|\\^])+/g, ''); - // starts with http:// - if (!r.startsWith('http://')) r = `http://${r}`; - - return r; -}; diff --git a/apps/server/src/utils/url.ts b/apps/server/src/utils/url.ts new file mode 100644 index 000000000..9f8fd7bba --- /dev/null +++ b/apps/server/src/utils/url.ts @@ -0,0 +1,20 @@ +/** + * @description Cleans given url + * @param {string} url - URL to be checked + * @returns {string} Sanitized url + */ +export const cleanURL = (url: string): string => { + // trim whitespaces + let sanitised = url.trim(); + + // clear any whitespaces + sanitised = sanitised.split(' ').join('%20'); + + // contain only allowed characters + sanitised = sanitised.replace(/([@\s<>[\]{}|\\^])+/g, ''); + + // starts with http:// + if (!sanitised.startsWith('http://')) sanitised = `http://${sanitised}`; + + return sanitised; +}; diff --git a/e2e/tests/002-upload-showfile.spec.ts b/e2e/tests/002-upload-showfile.spec.ts index 539c5e22f..d522581c6 100644 --- a/e2e/tests/002-upload-showfile.spec.ts +++ b/e2e/tests/002-upload-showfile.spec.ts @@ -13,13 +13,13 @@ test('test project file upload', async ({ page }) => { // https://playwright.dev/docs/api/class-filechooser const [fileChooser] = await Promise.all([ page.waitForEvent('filechooser'), - await page.getByText('Click to upload Ontime project file').click(), + await page.getByText('Click to select Ontime project or xlsx rundown').click(), ]); await fileChooser.setFiles(fileToUpload); + // there is only one step for normal imports await page.getByRole('button', { name: 'Import' }).click(); - await page.getByRole('button', { name: 'Close' }).click(); // asset test events await page.getByPlaceholder('Start').first().click(); diff --git a/package.json b/package.json index feb142137..c93e6a71e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dist-mac": "turbo run dist-mac", "dist-linux": "turbo run dist-linux", "e2e": "cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts", + "e2e:ui": "cross-env DEBUG=pw:webserver npx playwright test --ui -c playwright.config.ts", "e2e:i": "npx playwright codegen", "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules" }, diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 74cb630e7..d1a596718 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,7 +1,7 @@ import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; -type MaybeEvent = OntimeRundownEntry | null | undefined; +type MaybeEvent = Partial | null | undefined; export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { return event?.type === SupportedEvent.Event; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index d86894c2f..ac0060d56 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -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'; diff --git a/packages/utils/package.json b/packages/utils/package.json index 2d1d7dd2b..c11c74c9e 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -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" }, diff --git a/packages/utils/src/externals/deepmerge.ts b/packages/utils/src/externals/deepmerge.ts new file mode 100644 index 000000000..23bc35e07 --- /dev/null +++ b/packages/utils/src/externals/deepmerge.ts @@ -0,0 +1,2 @@ +// evaluating the library, we re-export to make it easy to detach +export { deepmerge } from 'deepmerge-ts'; diff --git a/packages/utils/src/feature/excel-import/excelImport.ts b/packages/utils/src/feature/excel-import/excelImport.ts new file mode 100644 index 000000000..7ef041fe2 --- /dev/null +++ b/packages/utils/src/feature/excel-import/excelImport.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)); +} diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index 677fccf5e..892732965 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -1,8 +1,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 +69,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); - }); - }); -}); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 685a8ddf9..dbf75ad25 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -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 diff --git a/packages/utils/src/validate-events/validateEvent.test.ts b/packages/utils/src/validate-events/validateEvent.test.ts new file mode 100644 index 000000000..76eca2c99 --- /dev/null +++ b/packages/utils/src/validate-events/validateEvent.test.ts @@ -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); + }); + }); +}); diff --git a/packages/utils/src/validate-events/validateEvent.ts b/packages/utils/src/validate-events/validateEvent.ts new file mode 100644 index 000000000..4bae03016 --- /dev/null +++ b/packages/utils/src/validate-events/validateEvent.ts @@ -0,0 +1,85 @@ +import { EndAction, TimerType } from 'ontime-types'; + +import { dayInMs } from '../timeConstants.js'; + +/** + * Checks if given value is a valid type of EndAction, returns the fallback otherwise + * @param {EndAction} maybeAction + * @param {EndAction} [fallback] + */ +export function validateEndAction(maybeAction: unknown, fallback = EndAction.None) { + return Object.values(EndAction).includes(maybeAction as any) ? (maybeAction as EndAction) : fallback; +} + +/** + * Checks if given value is a valid type of TimerType, returns the fallback otherwise + * @param {TimerType} maybeTimerType + * @param {TimerType} [fallback] + */ +export function validateTimerType(maybeTimerType: unknown, fallback = TimerType.CountDown) { + return Object.values(TimerType).includes(maybeTimerType as any) ? (maybeTimerType as TimerType) : 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; +}; + +/** + * Converts a given value to an int, returns 0 otherwise + * @param value + * number + */ +function convertToInteger(value: unknown): number { + const result = Number(value); + return isNaN(result) ? 0 : Math.floor(result); +} + +/** + * Ensures the time input variables are valid in relationship to each other + * Infers values if necessary + * @param _start + * @param _end + * @param _duration + */ +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 }; +}