mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 02:43:50 +00:00
refactor: migrate project upload (#796)
* refactor: migrate project upload
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
+9
-10
@@ -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;
|
||||
};
|
||||
+2
-1
@@ -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';
|
||||
|
||||
@@ -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
-1
@@ -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`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user