refactor: migrate project upload (#796)

* refactor: migrate project upload
This commit is contained in:
Carlos Valente
2024-03-01 22:59:00 +01:00
committed by GitHub
parent 8df419d835
commit 5b01329c37
54 changed files with 698 additions and 1134 deletions
+16 -4
View File
@@ -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<void> {
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<MessageResponse> {
// 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;
}
@@ -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');
}
@@ -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<void>;
submitError: string | null;
}
export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const { onClose } = props;
const [error, setError] = useState<string | null>(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 (
<form onSubmit={handleSubmit(onSubmit)}>
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Create new project
<div className={style.createActionButtons}>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
Create
</Button>
</div>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.createFormInputField}>
<label>
Project title
@@ -115,22 +149,6 @@ export default function ProjectCreateForm({ onSubmit, onCancel, submitError }: P
/>
</label>
</div>
<div className={style.createActionButtons}>
<Button onClick={onCancel} variant='ontime-ghosted' size='sm'>
Cancel
</Button>
<Button
isDisabled={!isValid}
type='submit'
isLoading={isSubmitting}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
Create
</Button>
</div>
{submitError && <span className={style.error}>{submitError}</span>}
</form>
</Panel.Section>
);
}
@@ -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
</Button>
</div>
</form>
{submitError && <span className={style.error}>{submitError}</span>}
{submitError && <Panel.Error>{submitError}</Panel.Error>}
</>
);
}
@@ -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<EditMode | null>(null);
const [editingFilename, setEditingFilename] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(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
</tr>
</thead>
<tbody>
{isCreatingProject ? (
<tr className={style.createContainer}>
<td colSpan={99}>
<ProjectCreateForm onSubmit={handleSubmitCreate} onCancel={handleToggleCreateMode} submitError='' />
{submitError && <span className={style.createSubmitError}>{submitError}</span>}
</td>
</tr>
) : null}
{reorderedProjectFiles.map((project) => (
<ProjectListItem
key={project.filename}
@@ -85,9 +85,10 @@ export default function ProjectListItem({
};
const isCurrentlyBeingEdited = editingMode && filename === editingFilename;
const classes = current && !isCurrentlyBeingEdited ? style.current : undefined;
return (
<tr key={filename} className={current ? style.current : undefined}>
<tr key={filename} className={classes}>
{isCurrentlyBeingEdited ? (
<td colSpan={99}>
<ProjectForm
@@ -160,6 +161,7 @@ function ActionMenu({
as={IconButton}
aria-label='Options'
icon={<IoEllipsisHorizontal />}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
/>
@@ -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;
}
@@ -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<HTMLInputElement>(null);
const handleToggleCreate = () => {
setIsCreatingProject((prev) => !prev);
};
const handleSelectFile = () => {
fileInputRef.current?.click();
};
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
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 (
<>
<Panel.Header>Project</Panel.Header>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleImport}
accept='.json'
data-testid='file-input'
/>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Manage projects
<div style={{ display: 'flex', gap: '1rem' }}>
<Button variant='ontime-subtle' onClick={handleToggleCreate} size='sm'>
<div className={style.headerButtons}>
<Button
variant='ontime-subtle'
onClick={handleSelectFile}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
isLoading={loading === 'import'}
>
Import
</Button>
<Button variant='ontime-subtle' onClick={handleToggleCreate} size='sm'>
<Button
variant='ontime-subtle'
onClick={handleToggleCreate}
size='sm'
isDisabled={Boolean(loading) || isCreatingProject}
rightIcon={<IoAdd />}
>
Add
</Button>
</div>
</Panel.SubHeader>
<ProjectList onToggleCreate={handleToggleCreate} isCreatingProject={isCreatingProject} />
{error && <Panel.Error>{error}</Panel.Error>}
{isCreatingProject && <ProjectCreateForm onClose={() => setIsCreatingProject(false)} />}
<ProjectList />
</Panel.Card>
</Panel.Section>
</>
@@ -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 (
<>
<Panel.Header>Data sources</Panel.Header>
@@ -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 (
<>
<ErrorBoundary>
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
</ErrorBoundary>
<div className={styles.mainContainer} data-testid='event-editor'>
@@ -45,8 +42,6 @@ export default function Editor() {
isOldSettingsOpen={isOldSettingsOpen}
onSettingsOpen={onSettingsOpen}
onSettingsClose={onSettingsClose}
isUploadOpen={isUploadModalOpen}
onUploadOpen={onUploadModalOpen}
openSettings={handleSettings}
isSettingsOpen={isSettingsOpen}
/>
+5 -26
View File
@@ -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) => {
<div className={style.menu}>
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} />
<div className={style.gap} />
<TooltipActionBtn
{...buttonStyle}
isDisabled={appMode === AppMode.Run}
icon={<IoPushOutline />}
className={isUploadOpen ? style.open : ''}
clickHandler={onUploadOpen}
tooltip='Import project file'
aria-label='Import project file'
/>
<div className={style.gap} />
<RundownMenu>
<Tooltip label='Rundown...'>
<MenuButton as={IconButton} icon={<IoAdd />} {...buttonStyle} aria-label='Rundown menu' />
@@ -141,8 +120,8 @@ const MenuBar = (props: MenuBarProps) => {
icon={<IoSettingsOutline />}
className={isOldSettingsOpen ? style.open : ''}
clickHandler={onSettingsOpen}
tooltip='Settings'
aria-label='Settings'
tooltip='Settings deprecated'
aria-label='Settings deprecated'
/>
<TooltipActionBtn
@@ -150,8 +129,8 @@ const MenuBar = (props: MenuBarProps) => {
className={cx([isSettingsOpen ? style.open : null, style.bottom])}
icon={<IoSettingsOutline />}
clickHandler={() => openSettings()}
tooltip='About'
aria-label='About'
tooltip='Application settings'
aria-label='Application settings'
/>
</div>
);
@@ -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<HTMLInputElement>(null);
const { file, setFile, progress } = useUploadModalContextStore();
const [errors, setErrors] = useState<string>('');
const clearFile = () => {
setFile(null);
setErrors('');
};
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
setErrors('');
const selectedFile = event?.target?.files?.[0];
if (!selectedFile) {
setFile(null);
return;
}
try {
validateFile(selectedFile);
setFile(selectedFile);
} catch (error) {
if (error instanceof Error) {
setErrors(error.message);
} else {
setErrors('An unexpected error occurred while validating the file.');
}
setFile(null);
}
};
const handleClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Input
ref={fileInputRef}
style={{ display: 'none' }}
type='file'
onChange={handleFile}
accept='.json'
data-testid='file-input'
/>
{!file && (
<div className={style.uploadArea} onClick={handleClick} role='button'>
Click to select Ontime project
</div>
)}
{(file || errors) && <UploadEntry file={file} errors={errors} progress={progress} handleClear={clearFile} />}
</>
);
}
@@ -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;
}
@@ -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<UploadStep>('import');
const [submitting, setSubmitting] = useState(false);
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
const [errors, setErrors] = useState('');
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
const updateOntimeFileOptions = <T extends keyof ProjectFileImportOptions>(
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<ProjectFileImportOptions>) {
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 (
<Modal
onClose={handleClose}
isOpen={isOpen}
closeOnOverlayClick={false}
motionPreset='slideInBottom'
size='xl'
scrollBehavior='inside'
preserveScrollBarGap
variant='ontime-upload'
>
<ModalOverlay />
<ModalContent>
<ModalHeader>File import</ModalHeader>
<ModalCloseButton />
<ModalBody className={style.uploadBody}>
<>
<UploadFile />
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} updateOptions={updateOntimeFileOptions} />}
</>
</ModalBody>
<ModalFooter>
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
<div className={`${style.buttonSection} ${style.pad}`}>
<Button
onClick={handleGoBack}
isDisabled={disableGoBack || submitting}
variant='ontime-ghost-on-light'
size='sm'
>
Go Back
</Button>
<Button
onClick={handleSubmit}
isLoading={submitting}
isDisabled={disableSubmit}
variant='ontime-filled'
padding='0 2em'
size='sm'
>
{submitText}
</Button>
</div>
</ModalFooter>
</ModalContent>
</Modal>
);
}
@@ -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;
}
}
}
@@ -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 (
<div className={`${style.uploadedItem} ${style.error}`}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoWarningOutline className={style.icon} />
<span className={style.fileTitle}>{errors}</span>
<span className={style.fileInfo}>Please try again</span>
</div>
);
}
if (file) {
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
let fileType = '';
if (isOntimeFile(file)) {
fileType = 'Ontime Project File';
}
return (
<div className={style.uploadedItem}>
<IoClose className={style.cancelUpload} onClick={handleClear} />
<IoDocumentTextOutline className={style.icon} />
<span className={style.fileTitle}>{file.name}</span>
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
</div>
);
}
return null;
}
@@ -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<Partial<ProjectFileImportOptions>>;
updateOptions: <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => void;
}
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
const { optionsRef, updateOptions } = props;
return (
<div className={style.uploadOptions}>
<span className={style.title}>Import options</span>
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
<Switch
variant='ontime-on-light'
onChange={(e) => {
updateOptions('onlyRundown', e.target.checked);
}}
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
/>
</ModalSplitInput>
</div>
);
}
@@ -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;
}
@@ -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 (
<div className={style.stepRow}>
<div className={isImporting ? style.active : style.idle}>
<IoCheckmarkCircle />
Import
</div>
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
<div className={isReview ? style.active : style.inactive}>
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
Review
</div>
</div>
);
}
@@ -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<UploadModalContext>((set) => ({
file: null,
setFile: (file: File | null) => set({ file }),
progress: 0,
setProgress: (progress: number) => set({ progress }),
clear: () => set({ file: null, progress: 0 }),
}));
+11 -11
View File
@@ -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');
});
+1 -1
View File
@@ -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 {
@@ -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;
}
-20
View File
@@ -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
+49 -185
View File
@@ -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<Partial<RuntimeStore> | 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<void>}
*/
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<GetInfo>) => {
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<Alias[]>) => {
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<Alias[] | ErrorResponse>) => {
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<Settings>) => {
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<ViewSettings>) => {
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<ViewSettings | ErrorResponse>) => {
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<OSCSettings>) => {
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<ProjectFileListResponse | ErrorResponse>) => {
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<ProjectFi
export const loadProject: RequestHandler = async (req, res) => {
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}`,
@@ -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<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
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;
};
@@ -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();
};
+10 -1
View File
@@ -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();
+4 -4
View File
@@ -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);
+1 -1
View File
@@ -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;
@@ -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<Config>;
private configPath: string;
private pathToFile: string;
constructor() {
this.configPath = join(getAppDataPath(), 'config.json');
const adapter = new JSONFile<Config>(this.configPath);
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, null);
this.init();
@@ -29,7 +28,7 @@ class ConfigService {
await this.config.write();
}
async getConfig(): Promise<Config> {
async get(): Promise<Config> {
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);
@@ -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<Array<ProjectFile>>} 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<ProjectFile[]> {
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<ProjectFileListResponse> {
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<GetInfo> {
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<DatabaseModel>, 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<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
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;
};
@@ -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'];
@@ -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<string>): Array<string> {
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;
}
@@ -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';
+22
View File
@@ -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',
};
@@ -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);
@@ -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<number|*>}
* It will create an empty file from the model if the parsing fails
*/
const parseDb = async (fileToRead: string, adapterToUse: Low<DatabaseModel>) => {
if (validateFile(fileToRead)) {
const parseDatabase = async (fileToRead: string, adapterToUse: Low<DatabaseModel>) => {
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<DatabaseModel>(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();
@@ -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';
/**
@@ -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';
/**
@@ -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', () => {
@@ -1,5 +0,0 @@
export const ensureJsonExtension = (filename: string) => {
if (!filename) return filename;
return filename.includes('.json') ? filename : `${filename}.json`;
};
+27 -1
View File
@@ -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<string[]> {
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;
};
+1 -1
View File
@@ -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
@@ -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<string>} 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);
@@ -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<string>): Array<string> => {
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<Array<ProjectFile>>} 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<ProjectFile[]> => {
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;
}
};
@@ -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;
}
+29 -47
View File
@@ -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<Partial<ResponseOK>> => {
export function handleMaybeExcel(file: string, options: ImportOptions) {
const res: Partial<ResponseOK> = {};
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;
}
+1 -15
View File
@@ -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
@@ -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;
};
@@ -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();
};
+5 -6
View File
@@ -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);
});
}
});
+6 -8
View File
@@ -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();
+1 -1
View File
@@ -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