From 5b01329c37aa6878ffe96e185e6505b189d714ab Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Fri, 1 Mar 2024 22:59:00 +0100 Subject: [PATCH] refactor: migrate project upload (#796) * refactor: migrate project upload --- apps/client/src/common/api/ontimeApi.ts | 20 +- apps/client/src/common/utils/uploadUtils.ts | 31 --- .../panel/project-panel/ProjectCreateForm.tsx | 70 ++++-- .../panel/project-panel/ProjectForm.tsx | 4 +- .../panel/project-panel/ProjectList.tsx | 40 +-- .../panel/project-panel/ProjectListItem.tsx | 4 +- .../project-panel/ProjectPanel.module.scss | 25 +- .../panel/project-panel/ProjectPanel.tsx | 70 +++++- .../panel/sources-panel/SourcesPanel.tsx | 2 +- apps/client/src/features/editors/Editor.tsx | 5 - apps/client/src/features/menu/MenuBar.tsx | 31 +-- .../modals/upload-modal/UploadFile.tsx | 67 ----- .../upload-modal/UploadModal.module.scss | 47 ---- .../modals/upload-modal/UploadModal.tsx | 161 ------------ .../upload-entry/UploadEntry.module.scss | 50 ---- .../upload-modal/upload-entry/UploadEntry.tsx | 50 ---- .../upload-options/OntimeFileOptions.tsx | 31 --- .../upload-step/UploadStep.module.scss | 38 --- .../upload-modal/upload-step/UploadStep.tsx | 26 -- .../upload-modal/uploadModalContext.tsx | 21 -- apps/server/src/app.ts | 22 +- apps/server/src/classes/Logger.ts | 2 +- .../src/classes/data-provider/DataProvider.ts | 8 +- apps/server/src/config/config.ts | 20 -- .../src/controllers/ontimeController.ts | 234 ++++------------- .../controllers/ontimeController.validate.ts | 35 +-- .../controllers/projectController.validate.ts | 13 + apps/server/src/routes/apiRouter.ts | 11 +- apps/server/src/routes/ontimeRouter.ts | 8 +- apps/server/src/services/RestoreService.ts | 2 +- .../AppStateService.ts} | 19 +- .../project-service/ProjectService.ts | 235 ++++++++++++++++++ .../__tests__/ProjectService.test.ts} | 3 +- .../project-service/projectFileUtils.ts | 31 +++ .../services/sheet-service/SheetService.ts | 2 +- apps/server/src/setup/config.ts | 22 ++ apps/server/src/{setup.ts => setup/index.ts} | 54 ++-- apps/server/src/{modules => setup}/loadDb.ts | 39 +-- .../server/src/{modules => setup}/loadDemo.ts | 2 +- .../src/{modules => setup}/loadStyles.ts | 2 +- ...tension.test.ts => fileManagement.test.ts} | 2 +- apps/server/src/utils/ensureJsonExtension.ts | 5 - apps/server/src/utils/fileManagement.ts | 28 ++- apps/server/src/utils/generateCrashReport.ts | 2 +- .../src/utils/generateUniqueFilename.ts | 6 +- .../server/src/utils/getFileListFromFolder.ts | 56 ----- apps/server/src/utils/networkInterfaces.ts | 24 ++ apps/server/src/utils/parser.ts | 76 +++--- apps/server/src/utils/parserUtils.ts | 16 +- .../utils/{routerUtils.js => routerUtils.ts} | 20 +- .../src/utils/sanitizeProjectFilename.ts | 13 - apps/server/src/utils/upload.ts | 11 +- e2e/tests/000-upload-showfile.spec.ts | 14 +- e2e/tests/features/206-alias.spec.ts | 2 +- 54 files changed, 698 insertions(+), 1134 deletions(-) delete mode 100644 apps/client/src/features/modals/upload-modal/UploadFile.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/UploadModal.module.scss delete mode 100644 apps/client/src/features/modals/upload-modal/UploadModal.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss delete mode 100644 apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss delete mode 100644 apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx delete mode 100644 apps/client/src/features/modals/upload-modal/uploadModalContext.tsx rename apps/server/src/services/{ConfigService.ts => app-state-service/AppStateService.ts} (57%) create mode 100644 apps/server/src/services/project-service/ProjectService.ts rename apps/server/src/{utils/__tests__/getFileListFromFolder.test.ts => services/project-service/__tests__/ProjectService.test.ts} (93%) create mode 100644 apps/server/src/services/project-service/projectFileUtils.ts create mode 100644 apps/server/src/setup/config.ts rename apps/server/src/{setup.ts => setup/index.ts} (72%) rename apps/server/src/{modules => setup}/loadDb.ts (65%) rename apps/server/src/{modules => setup}/loadDemo.ts (96%) rename apps/server/src/{modules => setup}/loadStyles.ts (95%) rename apps/server/src/utils/__tests__/{ensureJsonExtension.test.ts => fileManagement.test.ts} (93%) delete mode 100644 apps/server/src/utils/ensureJsonExtension.ts delete mode 100644 apps/server/src/utils/getFileListFromFolder.ts create mode 100644 apps/server/src/utils/networkInterfaces.ts rename apps/server/src/utils/{routerUtils.js => routerUtils.ts} (67%) delete mode 100644 apps/server/src/utils/sanitizeProjectFilename.ts diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index cc2da4c7d..d9456ce26 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -135,6 +135,21 @@ export const downloadRundown = (fileName?: string) => { ); }; +/** + * @description HTTP request to upload project file + * @return {Promise} + */ +export async function importProjectFile(file: File): Promise { + const formData = new FormData(); + formData.append('userFile', file); + const response = await axios.post(`${ontimeURL}/db`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +} + // TODO: should this be extracted to shared code? export type ProjectFileImportOptions = { onlyRundown: boolean; @@ -341,10 +356,7 @@ export async function createProject( } >, ): Promise { - // TODO: is this URL correct? - const url = `${ontimeURL}/project`; - const decodedUrl = decodeURIComponent(url); - const res = await axios.post(decodedUrl, project); + const res = await axios.post(`${ontimeURL}/project`, project); return res.data; } diff --git a/apps/client/src/common/utils/uploadUtils.ts b/apps/client/src/common/utils/uploadUtils.ts index a04f064f3..e76a98fc6 100644 --- a/apps/client/src/common/utils/uploadUtils.ts +++ b/apps/client/src/common/utils/uploadUtils.ts @@ -38,37 +38,6 @@ export function validateProjectFile(file: File) { } } -/** - * 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'); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx index 6e3e6cd0a..767e003f4 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectCreateForm.tsx @@ -1,10 +1,18 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Button, Input, Textarea } from '@chakra-ui/react'; +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import { createProject } from '../../../../common/api/ontimeApi'; +import * as Panel from '../PanelUtils'; + import style from './ProjectPanel.module.scss'; -export type ProjectCreateFormValues = { +interface ProjectCreateFromProps { + onClose: () => void; +} + +type ProjectCreateFormValues = { title?: string; description?: string; publicInfo?: string; @@ -13,13 +21,11 @@ export type ProjectCreateFormValues = { backstageUrl?: string; }; -interface ProjectCreateFormProps { - onCancel: () => void; - onSubmit: (values: ProjectCreateFormValues) => Promise; - submitError: string | null; -} +export default function ProjectCreateForm(props: ProjectCreateFromProps) { + const { onClose } = props; + + const [error, setError] = useState(null); -export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: ProjectCreateFormProps) { const { handleSubmit, register, @@ -33,12 +39,40 @@ export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: P }, }); + // set focus to first field useEffect(() => { setFocus('title'); }, [setFocus]); + const handleSubmitCreate = async (values: ProjectCreateFormValues) => { + try { + setError(null); + const filename = values.title?.trim(); + + await createProject({ + ...values, + filename, + }); + onClose(); + } catch (error) { + setError(maybeAxiosError(error)); + } + }; + return ( -
+ + + Create new project +
+ + +
+
+ {error && {error}}
-
- - -
- {submitError && {submitError}} - +
); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx index d2e084dea..271868d1d 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectForm.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { Button, Input } from '@chakra-ui/react'; +import * as Panel from '../PanelUtils'; + import style from './ProjectPanel.module.scss'; export type ProjectFormValues = { @@ -62,7 +64,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel, subm - {submitError && {submitError}} + {submitError && {submitError}} ); } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx index 9fefad91c..fb1eb2605 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectList.tsx @@ -1,48 +1,18 @@ import { useMemo, useState } from 'react'; -import { maybeAxiosError } from '../../../../common/api/apiUtils'; -import { createProject } from '../../../../common/api/ontimeApi'; import { useProjectList } from '../../../../common/hooks-query/useProjectList'; import * as Panel from '../PanelUtils'; -import ProjectCreateForm, { ProjectCreateFormValues } from './ProjectCreateForm'; import ProjectListItem, { EditMode } from './ProjectListItem'; import style from './ProjectPanel.module.scss'; -interface ProjectListProps { - isCreatingProject: boolean; - onToggleCreate: () => void; -} - -export default function ProjectList({ isCreatingProject, onToggleCreate }: ProjectListProps) { +export default function ProjectList() { const { data, refetch } = useProjectList(); const { files, lastLoadedProject } = data; const [editingMode, setEditingMode] = useState(null); const [editingFilename, setEditingFilename] = useState(null); - const [submitError, setSubmitError] = useState(null); - - const handleToggleCreateMode = () => { - onToggleCreate(); - setSubmitError(null); - }; - - const handleSubmitCreate = async (values: ProjectCreateFormValues) => { - try { - setSubmitError(null); - const filename = values.title?.trim(); - - await createProject({ - ...values, - filename, - }); - await refetch(); - handleToggleCreateMode(); - } catch (error) { - setSubmitError(maybeAxiosError(error)); - } - }; const handleToggleEditMode = (editMode: EditMode, filename: string | null) => { setEditingMode((prev) => (prev === editMode && filename === editingFilename ? null : editMode)); @@ -79,14 +49,6 @@ export default function ProjectList({ isCreatingProject, onToggleCreate }: Proje - {isCreatingProject ? ( - - - - {submitError && {submitError}} - - - ) : null} {reorderedProjectFiles.map((project) => ( + {isCurrentlyBeingEdited ? ( } + color='#e2e2e2' // $gray-200 variant='ontime-ghosted' size='sm' /> diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss index fa41d46c5..cb563fcac 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.module.scss @@ -1,6 +1,5 @@ .current { - color: $blue-400; - background-color: $gray-1350; + background-color: $blue-900; } .actionButton { @@ -23,25 +22,23 @@ padding-bottom: 0.5rem; } -.actionButtons { +.headerButtons, +.actionButtons, +.createActionButtons { display: flex; align-items: center; - gap: 0.5rem; + gap: 1rem; +} + +.actionButtons { margin-left: 1rem; } .createActionButtons { - display: flex; - align-items: center; - gap: 0.5rem; margin-left: 1rem; justify-content: flex-end; } -.error { - color: $red-500; -} - .createContainer { padding: 0.5rem 0; } @@ -50,12 +47,6 @@ text-transform: capitalize; } -.createSubmitError { - font-size: calc(1rem - 2px); - font-weight: 400; - color: $red-500; -} - .containCell { max-width: 400px; } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx index f5a0d31a4..4a1328203 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectPanel.tsx @@ -1,34 +1,92 @@ -import { useState } from 'react'; -import { Button } from '@chakra-ui/react'; +import { ChangeEvent, useRef, useState } from 'react'; +import { Button, Input } from '@chakra-ui/react'; +import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; +import { invalidateAllCaches, maybeAxiosError } from '../../../../common/api/apiUtils'; +import { importProjectFile } from '../../../../common/api/ontimeApi'; +import { validateProjectFile } from '../../../../common/utils/uploadUtils'; import * as Panel from '../PanelUtils'; +import ProjectCreateForm from './ProjectCreateForm'; import ProjectList from './ProjectList'; +import style from './ProjectPanel.module.scss'; + export default function ProjectPanel() { const [isCreatingProject, setIsCreatingProject] = useState(false); + const [error, setError] = useState(''); + const [loading, setLoading] = useState<'import' | null>(null); + + const fileInputRef = useRef(null); const handleToggleCreate = () => { setIsCreatingProject((prev) => !prev); }; + const handleSelectFile = () => { + fileInputRef.current?.click(); + }; + + const handleImport = async (event: ChangeEvent) => { + const selectedFile = event.target?.files?.[0]; + if (!selectedFile) { + return; + } + + setLoading('import'); + + try { + validateProjectFile(selectedFile); + await importProjectFile(selectedFile); + } catch (error) { + const errorMessage = maybeAxiosError(error); + setError(`Error uploading file: ${errorMessage}`); + } finally { + invalidateAllCaches(); + } + + setLoading(null); + }; + return ( <> Project + Manage projects -
- -
- + {error && {error}} + {isCreatingProject && setIsCreatingProject(false)} />} +
diff --git a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx index 650d6c47d..9f552b251 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/SourcesPanel.tsx @@ -98,6 +98,7 @@ export default function SourcesPanel() { } } }; + const handleFinished = () => { setImportFlow('none'); setRundown(null); @@ -119,7 +120,6 @@ export default function SourcesPanel() { const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile); const showReview = rundown !== null && customFields !== null; - console.log(isAuthenticated); return ( <> Data sources diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 0747bb096..016533297 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary' import AppSettings from '../app-settings/AppSettings'; import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore'; import MenuBar from '../menu/MenuBar'; -import UploadModal from '../modals/upload-modal/UploadModal'; import Overview from '../overview/Overview'; import styles from './Editor.module.scss'; @@ -24,7 +23,6 @@ export default function Editor() { }; const { isOpen: isOldSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); - const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure(); // Set window title useEffect(() => { @@ -36,7 +34,6 @@ export default function Editor() { return ( <> -
@@ -45,8 +42,6 @@ export default function Editor() { isOldSettingsOpen={isOldSettingsOpen} onSettingsOpen={onSettingsOpen} onSettingsClose={onSettingsClose} - isUploadOpen={isUploadModalOpen} - onUploadOpen={onUploadModalOpen} openSettings={handleSettings} isSettingsOpen={isSettingsOpen} /> diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index a4efe788d..b1744f72a 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -3,7 +3,6 @@ import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; -import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline'; @@ -21,8 +20,6 @@ interface MenuBarProps { isOldSettingsOpen: boolean; onSettingsOpen: () => void; onSettingsClose: () => void; - isUploadOpen: boolean; - onUploadOpen: () => void; openSettings: (newTab?: string) => void; isSettingsOpen: boolean; } @@ -40,15 +37,7 @@ const buttonStyle = { }; const MenuBar = (props: MenuBarProps) => { - const { - isOldSettingsOpen, - onSettingsOpen, - onSettingsClose, - isUploadOpen, - onUploadOpen, - openSettings, - isSettingsOpen, - } = props; + const { isOldSettingsOpen, onSettingsOpen, onSettingsClose, openSettings, isSettingsOpen } = props; const { isElectron, sendToElectron } = useElectronEvent(); const appMode = useAppMode((state) => state.mode); @@ -95,16 +84,6 @@ const MenuBar = (props: MenuBarProps) => {
- } - className={isUploadOpen ? style.open : ''} - clickHandler={onUploadOpen} - tooltip='Import project file' - aria-label='Import project file' - /> -
} {...buttonStyle} aria-label='Rundown menu' /> @@ -141,8 +120,8 @@ const MenuBar = (props: MenuBarProps) => { icon={} className={isOldSettingsOpen ? style.open : ''} clickHandler={onSettingsOpen} - tooltip='Settings' - aria-label='Settings' + tooltip='Settings deprecated' + aria-label='Settings deprecated' /> { className={cx([isSettingsOpen ? style.open : null, style.bottom])} icon={} clickHandler={() => openSettings()} - tooltip='About' - aria-label='About' + tooltip='Application settings' + aria-label='Application settings' />
); diff --git a/apps/client/src/features/modals/upload-modal/UploadFile.tsx b/apps/client/src/features/modals/upload-modal/UploadFile.tsx deleted file mode 100644 index 396ad2015..000000000 --- a/apps/client/src/features/modals/upload-modal/UploadFile.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { ChangeEvent, useRef, useState } from 'react'; -import { Input } from '@chakra-ui/react'; - -import { validateFile } from '../../../common/utils/uploadUtils'; - -import UploadEntry from './upload-entry/UploadEntry'; -import { useUploadModalContextStore } from './uploadModalContext'; - -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 -
- )} - {(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 deleted file mode 100644 index 8cdbd60d6..000000000 --- a/apps/client/src/features/modals/upload-modal/UploadModal.module.scss +++ /dev/null @@ -1,47 +0,0 @@ -@import "../Modal.module.scss"; - -.uploadBody { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.uploadArea { - margin: 0 auto; - width: 100%; - 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: $modal-note-color; - } -} - -.uploadOptions { - display: flex; - flex-direction: column; -} - -.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 deleted file mode 100644 index 2d53a6514..000000000 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { - Button, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalFooter, - ModalHeader, - ModalOverlay, -} from '@chakra-ui/react'; -import { OntimeRundown } from 'ontime-types'; - -import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils'; -import { ProjectFileImportOptions, uploadProjectFile } from '../../../common/api/ontimeApi'; -import { isOntimeFile } from '../../../common/utils/uploadUtils'; - -import OntimeFileOptions from './upload-options/OntimeFileOptions'; -import UploadFile from './UploadFile'; -import { useUploadModalContextStore } from './uploadModalContext'; - -import style from './UploadModal.module.scss'; - -export type UploadStep = 'import' | 'review'; - -interface UploadModalProps { - onClose: () => void; - isOpen: boolean; -} - -export default function UploadModal({ onClose, isOpen }: UploadModalProps) { - const { file, setProgress, clear } = useUploadModalContextStore(); - - const [uploadStep, setUploadStep] = useState('import'); - const [submitting, setSubmitting] = useState(false); - const [rundown, setRundown] = useState(null); - - const [errors, setErrors] = useState(''); - - const ontimeFileOptions = useRef>({}); - - const updateOntimeFileOptions = ( - field: T, - value: ProjectFileImportOptions[T], - ) => { - ontimeFileOptions.current = { ...ontimeFileOptions.current, [field]: value }; - }; - - // if the modal re-opens, we want to restart all states - useEffect(() => { - clear(); - setUploadStep('import'); - setSubmitting(false); - setRundown(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 { - 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; - } - } catch (error) { - const message = maybeAxiosError(error); - setErrors(`Failed uploading file ${message}`); - } finally { - setSubmitting(false); - if (doClose) { - handleClose(); - } - } - } - - // 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 = () => { - clear(); - setRundown([]); - onClose(); - }; - - const undoReview = () => { - setUploadStep('import'); - setErrors(''); - }; - - const isImporting = uploadStep === 'import'; - const isReview = uploadStep === 'review'; - const isOntime = isOntimeFile(file); - - const handleGoBack = isImporting ? undefined : undoReview; - const handleSubmit = handleUpload; - const disableSubmit = (isImporting && !file) || (isReview && rundown === null); - const disableGoBack = isImporting; - const submitText = isImporting ? 'Import' : 'Finish'; - - return ( - - - - File import - - - <> - - {isOntime && } - - - -
{errors &&
{errors}
}
-
- - -
-
-
-
- ); -} 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 deleted file mode 100644 index 17c04bb0b..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.module.scss +++ /dev/null @@ -1,50 +0,0 @@ -.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 deleted file mode 100644 index e59649316..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-entry/UploadEntry.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 { isOntimeFile } from '../../../../common/utils/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'; - } - - return ( -
- - - {file.name} - {`${fileSize} - ${fileType}`} - -
- ); - } - - return null; -} 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 deleted file mode 100644 index 812bfd2cc..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-options/OntimeFileOptions.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 414c2cf3d..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.module.scss +++ /dev/null @@ -1,38 +0,0 @@ -@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 deleted file mode 100644 index f3c369dee..000000000 --- a/apps/client/src/features/modals/upload-modal/upload-step/UploadStep.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index e17bc0869..000000000 --- a/apps/client/src/features/modals/upload-modal/uploadModalContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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/server/src/app.ts b/apps/server/src/app.ts index d5c508116..de42302b6 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -7,16 +7,16 @@ import http, { type Server } from 'http'; import cors from 'cors'; // import utils -import { join, resolve } from 'path'; +import { resolve } from 'path'; import { - currentDirectory, + srcDirectory, environment, isProduction, resolveDbPath, resolveExternalsDirectory, resolveStylesDirectory, resolvedPath, -} from './setup.js'; +} from './setup/index.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; // Import Routes @@ -29,19 +29,19 @@ import { router as apiRouter } from './routes/apiRouter.js'; import { OscServer } from './adapters/OscAdapter.js'; import { socket } from './adapters/WebsocketAdapter.js'; import { DataProvider } from './classes/data-provider/DataProvider.js'; -import { dbLoadingProcess } from './modules/loadDb.js'; +import { dbLoadingProcess } from './setup/loadDb.js'; // Services import { integrationService } from './services/integration-service/IntegrationService.js'; import { logger } from './classes/Logger.js'; import { oscIntegration } from './services/integration-service/OscIntegration.js'; import { httpIntegration } from './services/integration-service/HttpIntegration.js'; -import { populateStyles } from './modules/loadStyles.js'; +import { populateStyles } from './setup/loadStyles.js'; import { eventStore } from './stores/EventStore.js'; import { runtimeService } from './services/runtime-service/RuntimeService.js'; import { restoreService } from './services/RestoreService.js'; import { messageService } from './services/message-service/MessageService.js'; -import { populateDemo } from './modules/loadDemo.js'; +import { populateDemo } from './setup/loadDemo.js'; import { getState, updateRundownData } from './stores/runtimeState.js'; import { initRundown } from './services/rundown-service/RundownService.js'; import { getPlayableEvents } from './services/rundown-service/rundownUtils.js'; @@ -51,7 +51,7 @@ console.log(`Starting Ontime version ${ONTIME_VERSION}`); if (!isProduction) { console.log(`Ontime running in ${environment} environment`); - console.log(`Ontime directory at ${currentDirectory} `); + console.log(`Ontime directory at ${srcDirectory} `); console.log(`Ontime database at ${resolveDbPath}`); } @@ -83,7 +83,7 @@ app.use('/external', (req, res) => { }); // serve static - react, in dev/test mode we fetch the React app from module -const reactAppPath = join(currentDirectory, resolvedPath()); +const reactAppPath = resolvedPath(); app.use( expressStaticGzip(reactAppPath, { enableBrotli: true, @@ -91,12 +91,12 @@ app.use( }), ); -app.get('*', (req, res) => { - res.sendFile(resolve(currentDirectory, resolvedPath(), 'index.html')); +app.get('*', (_req, res) => { + res.sendFile(resolve(resolvedPath(), 'index.html')); }); // Implement catch all -app.use((error, response) => { +app.use((_error, response) => { response.status(400).send('Unhandled request'); }); diff --git a/apps/server/src/classes/Logger.ts b/apps/server/src/classes/Logger.ts index 9541c09bf..200f42986 100644 --- a/apps/server/src/classes/Logger.ts +++ b/apps/server/src/classes/Logger.ts @@ -2,7 +2,7 @@ import { Log, LogLevel } from 'ontime-types'; import { generateId, millisToString } from 'ontime-utils'; import { clock } from '../services/Clock.js'; -import { isProduction } from '../setup.js'; +import { isProduction } from '../setup/index.js'; import { socket } from '../adapters/WebsocketAdapter.js'; class Logger { diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index f13c817d8..3ef7b70c7 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -14,9 +14,9 @@ import { HttpSettings, } from 'ontime-types'; -import { data, db } from '../../modules/loadDb.js'; +import { data, db } from '../../setup/loadDb.js'; import { safeMerge } from './DataProvider.utils.js'; -import { isTest } from '../../setup.js'; +import { isTest } from '../../setup/index.js'; export class DataProvider { static getData() { @@ -48,7 +48,7 @@ export class DataProvider { await this.persist(); } - static getSettings() { + static getSettings(): Settings { return data.settings; } @@ -65,7 +65,7 @@ export class DataProvider { return data.http; } - static getAliases() { + static getAliases(): Alias[] { return data.aliases; } diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts index 5994efe5b..f4a7be87e 100644 --- a/apps/server/src/config/config.ts +++ b/apps/server/src/config/config.ts @@ -1,23 +1,3 @@ -export const config = { - database: { - testdb: 'test-db', - directory: 'db', - filename: 'db.json', - }, - styles: { - directory: 'styles', - filename: 'override.css', - }, - demo: { - directory: 'demo', - filename: ['app.js', 'index.html', 'styles.css'], - }, - sheets: { - directory: 'sheets', - }, - restoreFile: 'ontime.restore', -}; - export const timerConfig = { skipLimit: 1000, // threshold of skip for recalculating updateRate: 32, // how often do we update the timer diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index 0fc1c5dc6..885b83a08 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -7,44 +7,35 @@ import type { ErrorResponse, ProjectFileListResponse, OSCSettings, + RuntimeStore, + Settings, + ViewSettings, } from 'ontime-types'; -import { ImportOptions } from 'ontime-utils'; import { RequestHandler, Request, Response } from 'express'; import fs from 'fs'; -import { networkInterfaces } from 'os'; import { join } from 'path'; -import { copyFile, rename, writeFile } from 'fs/promises'; -import { fileHandler } from '../utils/parser.js'; import { DataProvider } from '../classes/data-provider/DataProvider.js'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; import { runtimeService } from '../services/runtime-service/RuntimeService.js'; import { eventStore } from '../stores/EventStore.js'; -import { - getAppDataPath, - isDocker, - lastLoadedProjectConfigPath, - resolveDbPath, - resolveStylesPath, - uploadsFolderPath, -} from '../setup.js'; +import { isDocker, resolveDbPath, resolveProjectsDirectory, uploadsFolderPath } from '../setup/index.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { httpIntegration } from '../services/integration-service/HttpIntegration.js'; -import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js'; -import { getProjectFiles } from '../utils/getFileListFromFolder.js'; -import { configService } from '../services/ConfigService.js'; -import { deleteFile } from '../utils/parserUtils.js'; -import { validateProjectFiles } from './ontimeController.validate.js'; -import { dbModel } from '../models/dataModel.js'; -import { removeFileExtension } from '../utils/removeFileExtension.js'; +import { setRundown } from '../services/rundown-service/RundownService.js'; +import { appStateService } from '../services/app-state-service/AppStateService.js'; import type { OntimeError } from '../utils/backend.types.js'; -import { ensureJsonExtension } from '../utils/ensureJsonExtension.js'; import { generateUniqueFileName } from '../utils/generateUniqueFilename.js'; +import * as projectService from '../services/project-service/ProjectService.js'; +import { extractPin } from '../services/project-service/ProjectService.js'; +import { handleMaybeExcel } from '../utils/parser.js'; +import { ensureJsonExtension } from '../utils/fileManagement.js'; + // Create controller for GET request to '/ontime/poll' // Returns data for current state -export const poll = async (_req: Request, res: Response) => { +export const poll = async (_req: Request, res: Response | ErrorResponse>) => { try { const state = eventStore.poll(); res.status(200).send(state); @@ -70,103 +61,23 @@ export const dbDownload = async (_req: Request, res: Response) => { }); }; -/** - * Parses a file and returns the result objects - * @param filePath - * @param _req - * @param _res - * @param options - */ -async function parseFile(filePath: string, _req: Request, _res: Response, options: ImportOptions) { - if (!fs.existsSync(filePath)) { - throw new Error('Upload failed'); - } - const result = await fileHandler(filePath, options); - return result.data; -} - -export type ParsingOptions = { - onlyRundown?: 'true' | 'false'; -}; - -/** - * parse an uploaded file and apply its parsed objects - * @param file - * @param _req - * @param res - * @param [options] - * @returns {Promise} - */ -const parseAndApply = async (file, _req: Request, res: Response, options) => { - const result = await parseFile(file, _req, res, options); - - runtimeService.stop(); - - const newRundown = result.rundown || []; - const { rundown, ...rest } = result; - if (options?.onlyRundown === 'true') { - setRundown(newRundown ?? []); - } else { - await DataProvider.mergeIntoData(rest); - setRundown(rundown ?? []); - } - notifyChanges({ timer: true, external: true }); -}; - -/** - * @description Gets information on IPV4 non-internal interfaces - * @returns {array} - Array of objects {name: ip} - */ -const getNetworkInterfaces = () => { - const nets = networkInterfaces(); - const results: { name: string; address: string }[] = []; - - for (const name of Object.keys(nets)) { - for (const net of nets[name]) { - // Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses - if (net.family === 'IPv4' && !net.internal) { - results.push({ - name, - address: net.address, - }); - } - } - } - - return results; -}; - // Create controller for GET request to '/ontime/info' // Returns - export const getInfo = async (_req: Request, res: Response) => { - const { version, serverPort } = DataProvider.getSettings(); - const osc = DataProvider.getOsc(); - - // get nif and inject localhost - const ni = getNetworkInterfaces(); - ni.unshift({ name: 'localhost', address: '127.0.0.1' }); - const cssOverride = resolveStylesPath; - - // send object with network information - res.status(200).send({ - networkInterfaces: ni, - version, - serverPort, - osc, - cssOverride, - }); + const info = await projectService.getInfo(); + res.status(200).send(info); }; -// Create controller for POST request to '/ontime/aliases' +// Create controller for GET request to '/ontime/aliases' // Returns - -export const getAliases = async (_req: Request, res: Response) => { +export const getAliases = async (_req: Request, res: Response) => { const aliases = DataProvider.getAliases(); res.status(200).send(aliases); }; // Create controller for POST request to '/ontime/aliases' // Returns ACK message -export const postAliases = async (req: Request, res: Response) => { +export const postAliases = async (req: Request, res: Response) => { if (failIsNotArray(req.body, res)) { return; } @@ -186,26 +97,13 @@ export const postAliases = async (req: Request, res: Response) => { } }; -// Create controller for POST request to '/ontime/settings' +// Create controller for GET request to '/ontime/settings' // Returns - -export const getSettings = async (_req: Request, res: Response) => { +export const getSettings = async (_req: Request, res: Response) => { const settings = DataProvider.getSettings(); res.status(200).send(settings); }; -function extractPin(value: string | undefined | null, fallback: string | null): string | null { - if (value === null) { - return value; - } - if (typeof value === 'undefined') { - return fallback; - } - if (value.length === 0) { - return null; - } - return value; -} - // Create controller for POST request to '/ontime/settings' // Returns ACK message export const postSettings = async (req: Request, res: Response) => { @@ -252,7 +150,7 @@ export const postSettings = async (req: Request, res: Response) => { /** * @description Get view Settings */ -export const getViewSettings = async (_req: Request, res: Response) => { +export const getViewSettings = async (_req: Request, res: Response) => { const views = DataProvider.getViewSettings(); res.status(200).send(views); }; @@ -260,7 +158,7 @@ export const getViewSettings = async (_req: Request, res: Response) => { /** * @description Change view Settings */ -export const postViewSettings = async (req: Request, res: Response) => { +export const postViewSettings = async (req: Request, res: Response) => { if (failEmptyObjects(req.body, res)) { return; } @@ -284,7 +182,7 @@ export const postViewSettings = async (req: Request, res: Response) => { // Create controller for GET request to '/ontime/osc' // Returns - -export const getOSC = async (_req: Request, res: Response) => { +export const getOSC = async (_req: Request, res: Response) => { const osc = DataProvider.getOsc(); res.status(200).send(osc); }; @@ -365,15 +263,16 @@ export async function patchPartialProjectFile(req: Request, res: Response) { /** * uploads, parses and applies the data from a given file */ -export const dbUpload = async (req: Request, res: Response) => { +export const uploadProjectFile = async (req: Request, res: Response) => { if (!req.file) { res.status(400).send({ message: 'File not found' }); return; } - const options = req.query; - const file = req.file.path; + try { - await parseAndApply(file, req, res, options); + const options = req.query; + const filePath = req.file.path; + await projectService.applyProjectFile(filePath, options); res.status(200).send(); } catch (error) { res.status(400).send({ message: `Failed parsing ${error}` }); @@ -391,9 +290,13 @@ export async function previewSpreadsheet(req: Request, res: Response) { } try { - const options = JSON.parse(req.body.options); const filePath = req.file.path; - const data = await parseFile(filePath, req, res, options); + if (!fs.existsSync(filePath)) { + throw new Error('Upload failed'); + } + + const options = JSON.parse(req.body.options); + const data = handleMaybeExcel(filePath, options); res.status(200).send(data); } catch (error) { res.status(500).send({ message: String(error) }); @@ -407,16 +310,8 @@ export async function previewSpreadsheet(req: Request, res: Response) { */ export const listProjects: RequestHandler = async (_req, res: Response) => { try { - const fileList = await getProjectFiles(); - - const lastLoadedProject = JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject; - - const lastLoadedProjectName = removeFileExtension(lastLoadedProject); - - res.status(200).send({ - files: fileList, - lastLoadedProject: lastLoadedProjectName, - }); + const data = await projectService.getProjectList(); + res.status(200).send(data); } catch (error) { res.status(500).send({ message: String(error) }); } @@ -430,16 +325,12 @@ export const listProjects: RequestHandler = async (_req, res: Response { try { const filename = req.body.filename; - - const uploadsFolderPath = join(getAppDataPath(), 'uploads'); - const filePath = join(uploadsFolderPath, filename); + const filePath = join(resolveProjectsDirectory, filename); if (!fs.existsSync(filePath)) { return res.status(404).send({ message: 'File not found' }); } - - await parseAndApply(filePath, req, res, {}); - + await projectService.applyProjectFile(filePath); res.status(200).send({ message: `Loaded project ${filename}`, }); @@ -463,16 +354,13 @@ export const duplicateProjectFile: RequestHandler = async (req: Request, res: Re const { filename } = req.params; const { newFilename } = req.body; - const projectFilePath = join(uploadsFolderPath, filename); - const duplicateProjectFilePath = join(uploadsFolderPath, newFilename); - - const errors = validateProjectFiles({ filename, newFilename }); + const errors = projectService.validateProjectFiles({ filename, newFilename }); if (errors.length) { return res.status(409).send({ message: errors.join(', ') }); } - await copyFile(projectFilePath, duplicateProjectFilePath); + await projectService.duplicateProjectFile(filename, newFilename); res.status(200).send({ message: `Duplicated project ${filename} to ${newFilename}`, @@ -497,24 +385,14 @@ export const renameProjectFile: RequestHandler = async (req: Request, res: Respo const { newFilename } = req.body; const { filename } = req.params; - const projectFilePath = join(uploadsFolderPath, filename); - const newProjectFilePath = join(uploadsFolderPath, newFilename); - - const errors = validateProjectFiles({ filename, newFilename }); + const errors = projectService.validateProjectFiles({ filename, newFilename }); if (errors.length) { return res.status(409).send({ message: errors.join(', ') }); } // Rename the file - await rename(projectFilePath, newProjectFilePath); - - // Update the last loaded project config if current loaded project is the one being renamed - const { lastLoadedProject } = await configService.getConfig(); - - if (lastLoadedProject === filename) { - await configService.updateDatabaseConfig(newFilename); - } + await projectService.renameProjectFile(filename, newFilename); res.status(200).send({ message: `Renamed project ${filename} to ${newFilename}`, @@ -537,10 +415,11 @@ export const createProjectFile: RequestHandler = async (req: Request, res: Respo try { const originalFilename = ensureJsonExtension(req.body.title || 'Untitled'); const filename = generateUniqueFileName(uploadsFolderPath, originalFilename); + const errors = projectService.validateProjectFiles({ newFilename: filename }); - const projectFilePath = join(uploadsFolderPath, filename); - - const errors = validateProjectFiles({ newFilename: filename }); + if (errors.length) { + return res.status(409).send({ message: 'Project with title already exists' }); + } const newProjectData: ProjectData = { title: req.body?.title ?? '', @@ -551,20 +430,7 @@ export const createProjectFile: RequestHandler = async (req: Request, res: Respo backstageInfo: req.body?.backstageInfo ?? '', }; - const data = { - ...dbModel, - project: { - ...dbModel.project, - ...newProjectData, - }, - }; - - if (errors.length) { - return res.status(409).send({ message: 'Project with title already exists' }); - } - - await writeFile(projectFilePath, JSON.stringify(data)); - await parseAndApply(projectFilePath, req, res, {}); + projectService.createProjectFile(filename, newProjectData); res.status(200).send({ filename, @@ -588,21 +454,19 @@ export const deleteProjectFile: RequestHandler = async (req: Request, res: Respo try { const { filename } = req.params; - const { lastLoadedProject } = await configService.getConfig(); + const { lastLoadedProject } = await appStateService.get(); if (lastLoadedProject === filename) { return res.status(403).send({ message: 'Cannot delete currently loaded project' }); } - const projectFilePath = join(uploadsFolderPath, filename); - - const errors = validateProjectFiles({ filename }); + const errors = projectService.validateProjectFiles({ filename }); if (errors.length) { return res.status(409).send({ message: errors.join(', ') }); } - await deleteFile(projectFilePath); + await projectService.deleteProjectFile(filename); res.status(200).send({ message: `Deleted project ${filename}`, diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 5d2a35215..7164e02fe 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -1,8 +1,6 @@ import { body, check, validationResult } from 'express-validator'; -import { join } from 'path'; -import { existsSync } from 'fs'; import { Request, Response, NextFunction } from 'express'; -import { uploadsFolderPath } from '../setup.js'; + import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../utils/parserFunctions.js'; /** @@ -168,34 +166,3 @@ export const validateProjectRename = [ next(); }, ]; - -/** - * @description Validates the existence of project files. - * @param {object} projectFiles - * @param {string} projectFiles.projectFilename - * @param {string} projectFiles.newFilename - * - * @returns {Promise>} Array of errors - * - */ -export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array => { - const errors: string[] = []; - - if (projectFiles.filename) { - const projectFilePath = join(uploadsFolderPath, projectFiles.filename); - - if (!existsSync(projectFilePath)) { - errors.push('Project file does not exist'); - } - } - - if (projectFiles.newFilename) { - const projectFilePath = join(uploadsFolderPath, projectFiles.newFilename); - - if (existsSync(projectFilePath)) { - errors.push('New project file already exists'); - } - } - - return errors; -}; diff --git a/apps/server/src/controllers/projectController.validate.ts b/apps/server/src/controllers/projectController.validate.ts index 3376ef5a5..3dda9344a 100644 --- a/apps/server/src/controllers/projectController.validate.ts +++ b/apps/server/src/controllers/projectController.validate.ts @@ -3,6 +3,8 @@ import { isAlphanumeric } from 'ontime-utils'; import { Request, Response, NextFunction } from 'express'; import { body, param, validationResult } from 'express-validator'; +import { ensureJsonExtension } from '../utils/fileManagement.js'; + export const projectSanitiser = [ body('title').optional().isString().trim(), body('description').optional().isString().trim(), @@ -59,3 +61,14 @@ export const validateDeleteCustomField = [ next(); }, ]; + +export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => { + const { filename, newFilename } = req.body; + const { filename: projectName } = req.params; + + req.body.filename = ensureJsonExtension(filename); + req.body.newFilename = ensureJsonExtension(newFilename); + req.params.filename = ensureJsonExtension(projectName); + + next(); +}; diff --git a/apps/server/src/routes/apiRouter.ts b/apps/server/src/routes/apiRouter.ts index 2b08a819c..19c14a163 100644 --- a/apps/server/src/routes/apiRouter.ts +++ b/apps/server/src/routes/apiRouter.ts @@ -1,7 +1,16 @@ +/** + * API Router + * User to handle all requests which affect runtime + * It is a mirror implementation of OSC and Websocket Adapters + * + */ + +import { LogOrigin } from 'ontime-types'; + import express from 'express'; + import { dispatchFromAdapter } from '../controllers/integrationController.js'; import { logger } from '../classes/Logger.js'; -import { LogOrigin } from 'ontime-types'; import { objectFromPath } from '../adapters/utils/parse.js'; export const router = express.Router(); diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index a8f58a277..61699d664 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -2,7 +2,7 @@ import express from 'express'; import { uploadClientSecret, uploadFile } from '../utils/upload.js'; import { dbDownload, - dbUpload, + uploadProjectFile, getAliases, getInfo, getOSC, @@ -36,8 +36,7 @@ import { validateLoadProjectFile, validateProjectRename, } from '../controllers/ontimeController.validate.js'; -import { projectSanitiser } from '../controllers/projectController.validate.js'; -import { sanitizeProjectFilename } from '../utils/sanitizeProjectFilename.js'; +import { projectSanitiser, sanitizeProjectFilename } from '../controllers/projectController.validate.js'; import { revokeAuthentication, readFromSheet, @@ -52,11 +51,12 @@ export const router = express.Router(); // create route between controller and '/ontime/sync' endpoint router.get('/poll', poll); +// TODO: should db be the root endpoint for /ontime/data // create route between controller and '/ontime/db' endpoint router.get('/db', dbDownload); // create route between controller and '/ontime/db' endpoint -router.post('/db', uploadFile, dbUpload); +router.post('/db', uploadFile, uploadProjectFile); // create route between controller and '/ontime/db' endpoint router.patch('/db', validatePatchProjectFile, patchPartialProjectFile); diff --git a/apps/server/src/services/RestoreService.ts b/apps/server/src/services/RestoreService.ts index d701f2827..cf08dfcb2 100644 --- a/apps/server/src/services/RestoreService.ts +++ b/apps/server/src/services/RestoreService.ts @@ -1,7 +1,7 @@ import { MaybeNumber, MaybeString, Playback } from 'ontime-types'; import { JSONFile } from 'lowdb/node'; -import { resolveRestoreFile } from '../setup.js'; +import { resolveRestoreFile } from '../setup/index.js'; export type RestorePoint = { playback: Playback; diff --git a/apps/server/src/services/ConfigService.ts b/apps/server/src/services/app-state-service/AppStateService.ts similarity index 57% rename from apps/server/src/services/ConfigService.ts rename to apps/server/src/services/app-state-service/AppStateService.ts index 7c86ce3cc..4155a64ee 100644 --- a/apps/server/src/services/ConfigService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -1,24 +1,23 @@ import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; -import { join } from 'path'; -import { getAppDataPath, isTest } from '../setup.js'; +import { appStatePath, isTest } from '../../setup/index.js'; interface Config { lastLoadedProject: string; } /** - * Service manages Ontime's runtime configuration + * Service manages Ontime's runtime memory between boots */ -class ConfigService { +class AppStateService { private config: Low; - private configPath: string; + private pathToFile: string; - constructor() { - this.configPath = join(getAppDataPath(), 'config.json'); - const adapter = new JSONFile(this.configPath); + constructor(appStatePath: string) { + this.pathToFile = appStatePath; + const adapter = new JSONFile(this.pathToFile); this.config = new Low(adapter, null); this.init(); @@ -29,7 +28,7 @@ class ConfigService { await this.config.write(); } - async getConfig(): Promise { + async get(): Promise { await this.config.read(); return this.config.data; } @@ -42,4 +41,4 @@ class ConfigService { } } -export const configService = new ConfigService(); +export const appStateService = new AppStateService(appStatePath); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts new file mode 100644 index 000000000..989728091 --- /dev/null +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -0,0 +1,235 @@ +import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types'; + +import { copyFile, rename, stat, writeFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { basename, join } from 'path'; + +import { notifyChanges, setRundown } from '../rundown-service/RundownService.js'; +import { DataProvider } from '../../classes/data-provider/DataProvider.js'; +import { runtimeService } from '../runtime-service/RuntimeService.js'; +import { getNetworkInterfaces } from '../../utils/networkInterfaces.js'; +import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js'; +import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js'; +import { appStateService } from '../app-state-service/AppStateService.js'; +import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js'; +import { dbModel } from '../../models/dataModel.js'; +import { deleteFile } from '../../utils/parserUtils.js'; + +// init dependencies +init(); + +/** + * Ensure services has its dependencies initialized + */ +function init() { + ensureDirectory(resolveProjectsDirectory); +} + +type Options = { + onlyRundown?: 'true' | 'false'; +}; + +/** + * Handles a file from the upload folder and applies its data + */ +export async function applyProjectFile(filePath: string, options?: Options) { + const data = parseProjectFile(filePath); + + // move file to project folder + const filename = basename(filePath); + const newFilePath = join(resolveProjectsDirectory, filename); + await rename(filePath, newFilePath); + + // apply data model + await applyDataModel(data, options); + + // persist the project selection + await appStateService.updateDatabaseConfig(filename); +} + +/** + * Asynchronously retrieves and returns an array of project files from the 'uploads' folder. + * Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed. + * For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time. + * + * @returns {Promise>} A promise that resolves to an array of ProjectFile objects, + * each representing a file in the 'uploads' folder with its metadata. + * The metadata includes the filename, creation time (createdAt), + * and last modification time (updatedAt) of each file. + * + * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. + */ +export async function getProjectFiles(): Promise { + const allFiles = await getFilesFromFolder(resolveProjectsDirectory); + const filteredFiles = filterProjectFiles(allFiles); + + const projectFiles = []; + for (const file of filteredFiles) { + const filePath = join(resolveProjectsDirectory, file); + const stats = await stat(filePath); + + projectFiles.push({ + filename: removeFileExtension(file), + createdAt: stats.birthtime.toISOString(), + updatedAt: stats.mtime.toISOString(), + }); + } + + return projectFiles; +} + +/** + * Gathers data related to the project list + */ +export async function getProjectList(): Promise { + const files = await getProjectFiles(); + const appState = await appStateService.get(); + const lastLoadedProject = removeFileExtension(appState.lastLoadedProject); + + return { + files, + lastLoadedProject, + }; +} + +/** + * Duplicates an existing project file + */ +export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) { + const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); + const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile); + + return copyFile(projectFilePath, duplicateProjectFilePath); +} + +/** + * Renames an existing project file + */ +export async function renameProjectFile(existingProjectFile: string, newName: string) { + const projectFilePath = join(resolveProjectsDirectory, existingProjectFile); + const newProjectFilePath = join(resolveProjectsDirectory, newName); + + await rename(projectFilePath, newProjectFilePath); + + // Update the last loaded project config if current loaded project is the one being renamed + const { lastLoadedProject } = await appStateService.get(); + + if (lastLoadedProject === existingProjectFile) { + await appStateService.updateDatabaseConfig(newName); + } +} + +/** + * Creates a new project file and applies its result + */ +export async function createProjectFile(filename: string, projectData: ProjectData) { + const data = { + ...dbModel, + project: { + ...dbModel.project, + ...projectData, + }, + }; + + // create new file + const newFile = join(resolveProjectsDirectory, filename); + await writeFile(newFile, JSON.stringify(data)); + + // apply its data + await applyDataModel(data); + + appStateService.updateDatabaseConfig(filename); +} + +/** + * Deletes a project file + */ +export async function deleteProjectFile(filename: string) { + const projectFilePath = join(resolveProjectsDirectory, filename); + await deleteFile(projectFilePath); +} + +/** + * Adds business logic to gathering data for the info endpoint + */ +export async function getInfo(): Promise { + const { version, serverPort } = DataProvider.getSettings(); + const osc = DataProvider.getOsc(); + + // get nif and inject localhost + const ni = getNetworkInterfaces(); + ni.unshift({ name: 'localhost', address: '127.0.0.1' }); + const cssOverride = resolveStylesPath; + + return { + networkInterfaces: ni, + version, + serverPort, + osc, + cssOverride, + }; +} + +/** + * Business logic for resolving a string + */ +export function extractPin(value: string | undefined | null, fallback: string | null): string | null { + if (value === null) { + return value; + } + if (typeof value === 'undefined') { + return fallback; + } + if (value.length === 0) { + return null; + } + return value; +} + +/** + * applies a partial database model + */ +export async function applyDataModel(data: Partial, options?: Options) { + runtimeService.stop(); + + const newRundown = data.rundown || []; + const { rundown, ...rest } = data; + if (options?.onlyRundown === 'true') { + setRundown(newRundown ?? []); + } else { + await DataProvider.mergeIntoData(rest); + setRundown(rundown ?? []); + } + notifyChanges({ timer: true, external: true }); +} + +/** + * @description Validates the existence of project files. + * @param {object} projectFiles + * @param {string} projectFiles.projectFilename + * @param {string} projectFiles.newFilename + * + * @returns {Promise>} Array of errors + * + */ +export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array => { + const errors: string[] = []; + + if (projectFiles.filename) { + const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename); + + if (!existsSync(projectFilePath)) { + errors.push('Project file does not exist'); + } + } + + if (projectFiles.newFilename) { + const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename); + + if (existsSync(projectFilePath)) { + errors.push('New project file already exists'); + } + } + + return errors; +}; diff --git a/apps/server/src/utils/__tests__/getFileListFromFolder.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts similarity index 93% rename from apps/server/src/utils/__tests__/getFileListFromFolder.test.ts rename to apps/server/src/services/project-service/__tests__/ProjectService.test.ts index e42ef9f40..877a2288f 100644 --- a/apps/server/src/utils/__tests__/getFileListFromFolder.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -1,5 +1,6 @@ import { expect, vi } from 'vitest'; -import { getProjectFiles } from '../getFileListFromFolder.js'; + +import { getProjectFiles } from '../ProjectService.js'; vi.mock('fs/promises', () => { const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png']; diff --git a/apps/server/src/services/project-service/projectFileUtils.ts b/apps/server/src/services/project-service/projectFileUtils.ts new file mode 100644 index 000000000..5864bc13a --- /dev/null +++ b/apps/server/src/services/project-service/projectFileUtils.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'fs'; +import { extname } from 'path'; + +/** + * Given an array of file names, filters out any files that do not have a '.json' extension. + * We assume these are project files + * @param files + * @returns + */ +export function filterProjectFiles(files: Array): Array { + return files.filter((file) => { + const ext = extname(file).toLowerCase(); + return ext === '.json'; + }); +} + +export function parseProjectFile(filePath: string): object { + if (!filePath.endsWith('.json')) { + throw new Error('Invalid file type'); + } + + const rawdata = readFileSync(filePath, 'utf-8'); + const uploadedJson = JSON.parse(rawdata); + + // at this point, we think this is a DatabaseModel + // verify by looking for the required fields + if (uploadedJson?.settings?.app !== 'ontime') { + throw new Error('Not a ontime project file'); + } + return uploadedJson; +} diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 604840067..459255ad7 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -10,7 +10,7 @@ import { sheets, sheets_v4 } from '@googleapis/sheets'; import { Credentials, OAuth2Client } from 'google-auth-library'; import got from 'got'; -import { resolveSheetsDirectory } from '../../setup.js'; +import { resolveSheetsDirectory } from '../../setup/index.js'; import { ensureDirectory } from '../../utils/fileManagement.js'; import { type ClientSecret, cellRequestFromEvent, getA1Notation, validateClientSecret } from './sheetUtils.js'; import { ImportMap } from 'ontime-utils'; diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts new file mode 100644 index 000000000..03ecfe4bc --- /dev/null +++ b/apps/server/src/setup/config.ts @@ -0,0 +1,22 @@ +export const config = { + appState: 'app-state.json', + database: { + testdb: 'test-db', + directory: 'db', + filename: 'db.json', + }, + demo: { + directory: 'demo', + filename: ['app.js', 'index.html', 'styles.css'], + }, + projects: 'projects', + sheets: { + directory: 'sheets', + }, + restoreFile: 'ontime.restore', + styles: { + directory: 'styles', + filename: 'override.css', + }, + uploads: 'uploads', +}; diff --git a/apps/server/src/setup.ts b/apps/server/src/setup/index.ts similarity index 72% rename from apps/server/src/setup.ts rename to apps/server/src/setup/index.ts index 7434b22a7..68b49a3b7 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup/index.ts @@ -2,8 +2,8 @@ import { fileURLToPath } from 'url'; import path, { dirname, join } from 'path'; import fs from 'fs'; -import { config } from './config/config.js'; -import { ensureDirectory } from './utils/fileManagement.js'; +import { config } from './config.js'; +import { ensureDirectory } from '../utils/fileManagement.js'; // ================================================= // resolve public path @@ -44,10 +44,20 @@ export const isDocker = env === 'docker'; export const isProduction = isDocker || (env === 'production' && !isTest); // ================================================= +// Resolve directory paths + +// resolve file URL in both CJS and ESM (build and dev) +if (import.meta.url) { + globalThis.__dirname = fileURLToPath(import.meta.url); +} + +// path to server src folder +export const srcDirectory = path.join(dirname(__dirname), '../'); + // resolve path to external -const productionPath = '../../resources/extraResources/client'; -const devPath = '../../client/build/'; -const dockerPath = 'client/'; +const productionPath = path.join(srcDirectory, '../../resources/extraResources/client'); +const devPath = path.join(srcDirectory, '../../client/build/'); +const dockerPath = path.join(srcDirectory, 'client/'); export const resolvedPath = (): string => { if (isTest) { @@ -62,30 +72,23 @@ export const resolvedPath = (): string => { return devPath; }; -// resolve file URL in both CJS and ESM (build and dev) -if (import.meta.url) { - globalThis.__dirname = fileURLToPath(import.meta.url); -} - -// path to server src folder -export const currentDirectory = dirname(__dirname); - const testDbStartDirectory = isTest ? '../' : getAppDataPath(); -export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external'); +export const externalsStartDirectory = isProduction ? getAppDataPath() : join(srcDirectory, 'external'); // TODO: we only need one when they are all in the same folder -export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external'); +export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : srcDirectory, 'external'); // project files -export const lastLoadedProjectConfigPath = join(getAppDataPath(), 'config.json'); -export const uploadsFolderPath = join(getAppDataPath(), 'uploads'); +export const appStatePath = join(getAppDataPath(), config.appState); +export const uploadsFolderPath = join(getAppDataPath(), config.uploads); const getLastLoadedProject = () => { try { - return JSON.parse(fs.readFileSync(lastLoadedProjectConfigPath, 'utf8')).lastLoadedProject; + const appState = JSON.parse(fs.readFileSync(appStatePath, 'utf8')); + return appState.lastLoadedProject; } catch { if (!isTest) { ensureDirectory(getAppDataPath()); - fs.writeFileSync(lastLoadedProjectConfigPath, JSON.stringify({ lastLoadedProject: 'db.json' })); + fs.writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' })); } } }; @@ -93,19 +96,19 @@ const getLastLoadedProject = () => { const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject(); // path to public db -export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : 'uploads'); +export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects); export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename); export const pathToStartDb = isTest - ? join(currentDirectory, '..', config.database.testdb, config.database.filename) - : join(currentDirectory, '/preloaded-db/', config.database.filename); + ? join(srcDirectory, '..', config.database.testdb, config.database.filename) + : join(srcDirectory, '/preloaded-db/', config.database.filename); // TODO: move all static files to the external directory // path to public styles export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory); export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename); -export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename); +export const pathToStartStyles = join(srcDirectory, '/external/styles/', config.styles.filename); // path to public demo export const resolveDemoDirectory = join( @@ -118,7 +121,7 @@ export const resolveDemoPath = config.demo.filename.map((file) => { }); export const pathToStartDemo = config.demo.filename.map((file) => { - return join(currentDirectory, '/external/demo/', file); + return join(srcDirectory, '/external/demo/', file); }); // path to restore file @@ -129,3 +132,6 @@ export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.direc // path to crash reports export const resolveCrashReportDirectory = getAppDataPath(); + +// path to projects +export const resolveProjectsDirectory = join(getAppDataPath(), config.projects); diff --git a/apps/server/src/modules/loadDb.ts b/apps/server/src/setup/loadDb.ts similarity index 65% rename from apps/server/src/modules/loadDb.ts rename to apps/server/src/setup/loadDb.ts index 7a324588f..df74ad8a1 100644 --- a/apps/server/src/modules/loadDb.ts +++ b/apps/server/src/setup/loadDb.ts @@ -1,26 +1,34 @@ +import { DatabaseModel } from 'ontime-types'; + import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; import { copyFileSync, existsSync } from 'fs'; -import { DatabaseModel } from 'ontime-types'; +import { join } from 'path'; import { ensureDirectory } from '../utils/fileManagement.js'; -import { validateFile } from '../utils/parserUtils.js'; import { dbModel } from '../models/dataModel.js'; + +import { pathToStartDb, resolveDbDirectory, resolveDbPath } from './index.js'; +import { parseProjectFile } from '../services/project-service/projectFileUtils.js'; import { parseJson } from '../utils/parser.js'; -import { pathToStartDb, resolveDbDirectory, resolveDbPath } from '../setup.js'; /** * @description ensures directories exist and populates database * @return {string} - path to db file */ -const populateDb = () => { - const dbInDisk = resolveDbPath; +const populateDb = (): string => { + // if everything goes well, the DB in disk is the one loaded + let dbInDisk = resolveDbPath; ensureDirectory(resolveDbDirectory); // if dbInDisk doesn't exist we want to use startup db if (!existsSync(dbInDisk)) { try { - copyFileSync(pathToStartDb, dbInDisk); + const dbDirectory = resolveDbDirectory; + const newFileDirectory = join(dbDirectory, pathToStartDb.split('/').pop()); + + copyFileSync(pathToStartDb, newFileDirectory); + dbInDisk = newFileDirectory; } catch (_) { /* we do not handle this */ } @@ -31,14 +39,14 @@ const populateDb = () => { /** * @description parses a json file to the adapter - * @param fileToRead - * @param adapterToUse - * @return {Promise} + * It will create an empty file from the model if the parsing fails */ -const parseDb = async (fileToRead: string, adapterToUse: Low) => { - if (validateFile(fileToRead)) { +const parseDatabase = async (fileToRead: string, adapterToUse: Low) => { + try { + // this will throw if file is not valid + parseProjectFile(fileToRead); await adapterToUse.read(); - } else { + } catch (error) { adapterToUse.data = dbModel; } @@ -55,12 +63,7 @@ async function loadDb() { const adapter = new JSONFile(dbInDisk); const db = new Low(adapter, dbModel); - const data = await parseDb(dbInDisk, db); - if (data === null) { - console.error('ERROR: Invalid JSON format'); - return; - } - + const data = await parseDatabase(dbInDisk, db); db.data = data; await db.write(); diff --git a/apps/server/src/modules/loadDemo.ts b/apps/server/src/setup/loadDemo.ts similarity index 96% rename from apps/server/src/modules/loadDemo.ts rename to apps/server/src/setup/loadDemo.ts index b94bf28cd..b44842542 100644 --- a/apps/server/src/modules/loadDemo.ts +++ b/apps/server/src/setup/loadDemo.ts @@ -1,5 +1,5 @@ import { copyFile } from 'fs/promises'; -import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from '../setup.js'; +import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from './index.js'; import { ensureDirectory } from '../utils/fileManagement.js'; /** diff --git a/apps/server/src/modules/loadStyles.ts b/apps/server/src/setup/loadStyles.ts similarity index 95% rename from apps/server/src/modules/loadStyles.ts rename to apps/server/src/setup/loadStyles.ts index f68f716e1..c55a41206 100644 --- a/apps/server/src/modules/loadStyles.ts +++ b/apps/server/src/setup/loadStyles.ts @@ -1,5 +1,5 @@ import { copyFileSync, existsSync } from 'fs'; -import { pathToStartStyles, resolveStylesDirectory, resolveStylesPath } from '../setup.js'; +import { pathToStartStyles, resolveStylesDirectory, resolveStylesPath } from './index.js'; import { ensureDirectory } from '../utils/fileManagement.js'; /** diff --git a/apps/server/src/utils/__tests__/ensureJsonExtension.test.ts b/apps/server/src/utils/__tests__/fileManagement.test.ts similarity index 93% rename from apps/server/src/utils/__tests__/ensureJsonExtension.test.ts rename to apps/server/src/utils/__tests__/fileManagement.test.ts index f652e898e..36db325cb 100644 --- a/apps/server/src/utils/__tests__/ensureJsonExtension.test.ts +++ b/apps/server/src/utils/__tests__/fileManagement.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { ensureJsonExtension } from '../ensureJsonExtension.js'; +import { ensureJsonExtension } from '../fileManagement.js'; describe('ensureJsonExtension', () => { it('should add .json to a filename without an extension', () => { diff --git a/apps/server/src/utils/ensureJsonExtension.ts b/apps/server/src/utils/ensureJsonExtension.ts deleted file mode 100644 index 0280e5427..000000000 --- a/apps/server/src/utils/ensureJsonExtension.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const ensureJsonExtension = (filename: string) => { - if (!filename) return filename; - - return filename.includes('.json') ? filename : `${filename}.json`; -}; diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index 40e8c9f86..62e9da0f8 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -1,10 +1,12 @@ import { existsSync, mkdirSync } from 'fs'; +import { readdir } from 'fs/promises'; +import { parse } from 'path'; /** * @description Creates a directory if it doesn't exist * @param {string} directory - directory that should exist or will be created */ -export function ensureDirectory(directory: string) { +export function ensureDirectory(directory: string): void { if (!existsSync(directory)) { try { mkdirSync(directory, { recursive: true }); @@ -13,3 +15,27 @@ export function ensureDirectory(directory: string) { } } } + +/** + * Ensures that a filename ends with .json extension + */ +export function ensureJsonExtension(filename: string): string { + if (!filename) return filename; + + return filename.includes('.json') ? filename : `${filename}.json`; +} + +/** + * Lists all files in a directory + */ +export async function getFilesFromFolder(folderPath: string): Promise { + return await readdir(folderPath); +} + +/** + * @description Takes a filename and removes the extension + * @param {string} filename - filename with extension + */ +export const removeFileExtension = (filename: string): string => { + return parse(filename).name; +}; diff --git a/apps/server/src/utils/generateCrashReport.ts b/apps/server/src/utils/generateCrashReport.ts index 82e954e6a..fb48af15d 100644 --- a/apps/server/src/utils/generateCrashReport.ts +++ b/apps/server/src/utils/generateCrashReport.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; import { get } from '../services/rundown-service/rundownCache.js'; import { getState } from '../stores/runtimeState.js'; -import { resolveCrashReportDirectory } from '../setup.js'; +import { resolveCrashReportDirectory } from '../setup/index.js'; /** * Writes a file to the crash report location diff --git a/apps/server/src/utils/generateUniqueFilename.ts b/apps/server/src/utils/generateUniqueFilename.ts index 22664eefd..a4ed70953 100644 --- a/apps/server/src/utils/generateUniqueFilename.ts +++ b/apps/server/src/utils/generateUniqueFilename.ts @@ -4,12 +4,8 @@ import path from 'path'; /** * Generates a unique file name within the specified directory. * If a file with the same name already exists, appends a counter to the filename. - * - * @param {string} directory - The directory to check for file existence. - * @param {string} filename - The original filename. - * @return {Promise} A unique filename. */ -export const generateUniqueFileName = (directory: string, filename: string) => { +export const generateUniqueFileName = (directory: string, filename: string): string => { const baseName = path.basename(filename, path.extname(filename)); const extension = path.extname(filename); diff --git a/apps/server/src/utils/getFileListFromFolder.ts b/apps/server/src/utils/getFileListFromFolder.ts deleted file mode 100644 index 5e1980fc3..000000000 --- a/apps/server/src/utils/getFileListFromFolder.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ProjectFile } from 'ontime-types'; - -import { getAppDataPath } from '../setup.js'; - -import { extname, join } from 'path'; -import { readdir, stat } from 'fs/promises'; -import { removeFileExtension } from './removeFileExtension.js'; - -const getFilesFromFolder = async (folderPath: string) => { - return await readdir(folderPath); -}; - -const filterProjectFiles = (files: Array): Array => { - return files.filter((file) => { - const ext = extname(file).toLowerCase(); - return ext === '.json'; - }); -}; - -/** - * Asynchronously retrieves and returns an array of project files from the 'uploads' folder. - * Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed. - * For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time. - * - * @returns {Promise>} A promise that resolves to an array of ProjectFile objects, - * each representing a file in the 'uploads' folder with its metadata. - * The metadata includes the filename, creation time (createdAt), - * and last modification time (updatedAt) of each file. - * - * @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics. - */ -export const getProjectFiles = async (): Promise => { - const uploadsFolderPath = join(getAppDataPath(), 'uploads'); - - try { - const allFiles = await getFilesFromFolder(uploadsFolderPath); - const filteredFiles = filterProjectFiles(allFiles); - - const projectFiles = []; - for (const file of filteredFiles) { - const filePath = join(uploadsFolderPath, file); - const stats = await stat(filePath); - - projectFiles.push({ - filename: removeFileExtension(file), - createdAt: stats.birthtime.toISOString(), - updatedAt: stats.mtime.toISOString(), - }); - } - - return projectFiles; - } catch (err) { - console.error(err); - throw err; - } -}; diff --git a/apps/server/src/utils/networkInterfaces.ts b/apps/server/src/utils/networkInterfaces.ts new file mode 100644 index 000000000..b1dfdd3d0 --- /dev/null +++ b/apps/server/src/utils/networkInterfaces.ts @@ -0,0 +1,24 @@ +import { networkInterfaces } from 'os'; + +/** + * @description Gets information on IPV4 non-internal interfaces + * @returns {array} - Array of objects {name: ip} + */ +export function getNetworkInterfaces(): { name: string; address: string }[] { + const nets = networkInterfaces(); + const results: { name: string; address: string }[] = []; + + for (const name of Object.keys(nets)) { + for (const net of nets[name]) { + // Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses + if (net.family === 'IPv4' && !net.internal) { + results.push({ + name, + address: net.address, + }); + } + } + } + + return results; +} diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 00438fd1f..928d71d9b 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -22,9 +22,7 @@ import { EventCustomFields, } from 'ontime-types'; -import fs from 'fs'; import xlsx from 'node-xlsx'; -import path from 'path'; import { event as eventDef } from '../models/eventsDefinition.js'; import { dbModel } from '../models/dataModel.js'; @@ -40,7 +38,6 @@ import { parseCustomFields, } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; -import { configService } from '../services/ConfigService.js'; import { coerceBoolean } from './coerceType.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -368,53 +365,38 @@ type ResponseOK = { }; /** - * @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 + * Validates and calls parse on an excel file */ -export const fileHandler = async (file: string, options: ImportOptions): Promise> => { +export function handleMaybeExcel(file: string, options: ImportOptions) { const res: Partial = {}; - const fileName = path.basename(file); - - // check which file type are we dealing with - if (file.endsWith('.xlsx')) { - // we need to check that the options are applicable - if (!isImportMap(options)) { - throw new Error('Got incorrect options for spreadsheet import'); - } - - 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.customFields = parseCustomFields(dataFromExcel); - - deleteFile(file); - - return res; + if (!file.endsWith('.xlsx')) { + throw new Error('unexpected extension for spreadsheet'); } - if (file.endsWith('.json')) { - const rawdata = fs.readFileSync(file).toString(); - let uploadedJson = null; - - uploadedJson = JSON.parse(rawdata); - res.data = await parseJson(uploadedJson); - - await configService.updateDatabaseConfig(fileName); - return res; + // we need to check that the options are applicable + if (!isImportMap(options)) { + throw new Error('Got incorrect options for spreadsheet import'); } -}; + + 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.customFields = parseCustomFields(dataFromExcel); + + deleteFile(file); + + return res; +} diff --git a/apps/server/src/utils/parserUtils.ts b/apps/server/src/utils/parserUtils.ts index 45e7d9f67..8eb85a0d3 100644 --- a/apps/server/src/utils/parserUtils.ts +++ b/apps/server/src/utils/parserUtils.ts @@ -1,4 +1,4 @@ -import { unlink, readFileSync } from 'fs'; +import { unlink } from 'fs'; import { deepmerge } from 'ontime-utils'; /** @@ -25,20 +25,6 @@ export const deleteFile = async (file) => { }); }; -/** - * @description Delete file from system - * @param {string} file - reference to file - * @returns {boolean} - whether file is valid JSON - */ -export const validateFile = (file) => { - try { - JSON.parse(readFileSync(file, 'utf-8')); - return true; - } catch (err) { - return false; - } -}; - /** * @description Verifies if object is empty * @param {object} obj diff --git a/apps/server/src/utils/routerUtils.js b/apps/server/src/utils/routerUtils.ts similarity index 67% rename from apps/server/src/utils/routerUtils.js rename to apps/server/src/utils/routerUtils.ts index b8ba6b147..1ba9da45e 100644 --- a/apps/server/src/utils/routerUtils.js +++ b/apps/server/src/utils/routerUtils.ts @@ -1,3 +1,5 @@ +import { Response } from 'express'; + import { isEmptyObject } from './parserUtils.js'; /** @@ -5,18 +7,17 @@ import { isEmptyObject } from './parserUtils.js'; * @param obj * @param res */ -export const failEmptyObjects = (obj, res) => { - let failed = false; +export const failEmptyObjects = (obj: object, res: Response): boolean => { try { if (isEmptyObject(obj)) { res.status(400).send('No object found in request'); - failed = true; + return true; } } catch (error) { res.status(400).send(error); - failed = true; + return true; } - return failed; + return false; }; /** @@ -24,16 +25,15 @@ export const failEmptyObjects = (obj, res) => { * @param obj * @param res */ -export const failIsNotArray = (obj, res) => { - let failed = false; +export const failIsNotArray = (obj: object, res: Response): boolean => { try { if (!Array.isArray(obj)) { res.status(400).send('No array found in request'); - failed = true; + return true; } } catch (error) { res.status(400).send(error); - failed = true; + return true; } - return failed; + return false; }; diff --git a/apps/server/src/utils/sanitizeProjectFilename.ts b/apps/server/src/utils/sanitizeProjectFilename.ts deleted file mode 100644 index 0c4b4ab68..000000000 --- a/apps/server/src/utils/sanitizeProjectFilename.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { ensureJsonExtension } from './ensureJsonExtension.js'; - -export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => { - const { filename, newFilename } = req.body; - const { filename: projectName } = req.params; - - req.body.filename = ensureJsonExtension(filename); - req.body.newFilename = ensureJsonExtension(newFilename); - req.params.filename = ensureJsonExtension(projectName); - - next(); -}; diff --git a/apps/server/src/utils/upload.ts b/apps/server/src/utils/upload.ts index 7a76c99b2..3d218e80e 100644 --- a/apps/server/src/utils/upload.ts +++ b/apps/server/src/utils/upload.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import { EXCEL_MIME, JSON_MIME } from './parser.js'; import { ensureDirectory } from './fileManagement.js'; -import { getAppDataPath } from '../setup.js'; +import { getAppDataPath, uploadsFolderPath } from '../setup/index.js'; function generateNewFileName(filePath: string, callback: (newName: string) => void) { const baseName = path.basename(filePath, path.extname(filePath)); @@ -37,20 +37,19 @@ const storage = multer.diskStorage({ throw new Error('Could not resolve public folder for platform'); } - const uploadsPath = path.join(appDataPath, 'uploads'); - ensureDirectory(uploadsPath); + ensureDirectory(uploadsFolderPath); - const filePath = path.join(uploadsPath, file.originalname); + const filePath = path.join(uploadsFolderPath, file.originalname); // Check if file already exists fs.access(filePath, fs.constants.F_OK, (err) => { if (err) { // File does not exist, can safely proceed to this destination - cb(null, uploadsPath); + cb(null, uploadsFolderPath); } else { generateNewFileName(filePath, (newName) => { file.originalname = newName; - cb(null, uploadsPath); + cb(null, uploadsFolderPath); }); } }); diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index a56aaffed..b5be8ff64 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -7,19 +7,17 @@ test('test project file upload', async ({ page }) => { await page.getByRole('button', { name: 'Rundown menu' }).click(); await page.getByRole('menuitem', { name: 'Delete all events' }).click(); - await page.getByRole('button', { name: 'Import project file' }).click(); + await page.getByRole('button', { name: 'Application settings' }).click(); + await page.getByRole('button', { name: 'Project', exact: true }).click(); // workaround to upload file on hidden input // https://playwright.dev/docs/api/class-filechooser - const [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser'), - await page.getByText('Click to select Ontime project').click(), - ]); - + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import' }).click(); + const fileChooser = await fileChooserPromise; 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.getByText('Albania').click(); diff --git a/e2e/tests/features/206-alias.spec.ts b/e2e/tests/features/206-alias.spec.ts index 5d6188b2c..516ce5012 100644 --- a/e2e/tests/features/206-alias.spec.ts +++ b/e2e/tests/features/206-alias.spec.ts @@ -4,7 +4,7 @@ test('test aliases feature, it should redirect to given alias', async ({ page }) await page.goto('http://localhost:4001/editor'); // open settings - await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('button', { name: 'Settings deprecated' }).click(); await page.getByRole('tab', { name: 'URL Aliases' }).click(); // create alias