mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 10:23:54 +00:00
Compare commits
28 Commits
master
...
v2.10.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b867b0dbc | |||
| 34b79e7699 | |||
| 4498a04a34 | |||
| d9df866308 | |||
| 8146d88765 | |||
| d5cb2735ec | |||
| 9b12afe2a6 | |||
| e10c0e8c97 | |||
| 4e9d9fc075 | |||
| 45ece13d04 | |||
| e5e5798272 | |||
| cf7f6bdbf7 | |||
| a0d4f40bec | |||
| ba5ef6668d | |||
| 9fc04f2fd1 | |||
| 13d72dd1a4 | |||
| 30905757f3 | |||
| e09a7d99f0 | |||
| 52ca04e063 | |||
| b8188c7485 | |||
| 85172ae8d7 | |||
| cb871a8c26 | |||
| 88a853c158 | |||
| 99cd9bf0b7 | |||
| 29fd145bd9 | |||
| f6440a503f | |||
| aa0d4103b1 | |||
| 12b051dadb |
@@ -2,18 +2,30 @@ import axios, { AxiosError } from 'axios';
|
|||||||
import { LogLevel } from 'ontime-types';
|
import { LogLevel } from 'ontime-types';
|
||||||
import { generateId, millisToString } from 'ontime-utils';
|
import { generateId, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
import { addLog } from '../stores/logger';
|
import { addLog } from '../stores/logger';
|
||||||
import { nowInMillis } from '../utils/time';
|
import { nowInMillis } from '../utils/time';
|
||||||
|
|
||||||
export function logAxiosError(prepend: string, error: unknown) {
|
export function maybeAxiosError(error: unknown) {
|
||||||
let message;
|
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
const statusText = (error as AxiosError).response?.statusText ?? '';
|
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||||
const data = (error as AxiosError).response?.data ?? '';
|
let data = (error as AxiosError).response?.data ?? '';
|
||||||
message = `${prepend} ${statusText}: ${data}`;
|
if (typeof data === 'object') {
|
||||||
|
// TODO: use error instead, when migrated
|
||||||
|
if ('message' in data) {
|
||||||
|
data = JSON.stringify(data.message);
|
||||||
|
} else {
|
||||||
|
data = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${statusText}: ${data}`;
|
||||||
} else {
|
} else {
|
||||||
message = `${prepend}: ${error}`;
|
return error as string;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logAxiosError(prepend: string, error: unknown) {
|
||||||
|
const message = `${prepend}: ${maybeAxiosError(error)}`;
|
||||||
|
|
||||||
addLog({
|
addLog({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -23,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) {
|
|||||||
text: message,
|
text: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function invalidateAllCaches() {
|
||||||
|
await ontimeQueryClient.invalidateQueries([
|
||||||
|
'project',
|
||||||
|
'aliases',
|
||||||
|
'userFields',
|
||||||
|
'rundown',
|
||||||
|
'appinfo',
|
||||||
|
'oscSettings',
|
||||||
|
'appSettings',
|
||||||
|
'viewSettings',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
import {
|
||||||
|
Alias,
|
||||||
|
DatabaseModel,
|
||||||
|
OntimeRundown,
|
||||||
|
OSCSettings,
|
||||||
|
OscSubscription,
|
||||||
|
ProjectData,
|
||||||
|
Settings,
|
||||||
|
UserFields,
|
||||||
|
ViewSettings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { apiRepoLatest } from '../../externals';
|
import { apiRepoLatest } from '../../externals';
|
||||||
import { InfoType } from '../models/Info';
|
import { InfoType } from '../models/Info';
|
||||||
@@ -137,17 +148,26 @@ export const downloadRundown = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: should this be extracted to shared code?
|
||||||
|
export type ProjectFileImportOptions = {
|
||||||
|
onlyRundown: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to upload events db
|
* @description HTTP request to upload events db
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
type UploadDataOptions = {
|
export const uploadProjectFile = async (
|
||||||
onlyRundown?: boolean;
|
file: File,
|
||||||
};
|
setProgress: (value: number) => void,
|
||||||
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
|
options?: Partial<ProjectFileImportOptions>,
|
||||||
|
) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('userFile', file);
|
formData.append('userFile', file);
|
||||||
const onlyRundown = options?.onlyRundown || 'false';
|
|
||||||
|
const onlyRundown = Boolean(options?.onlyRundown);
|
||||||
|
console.log('debug here', onlyRundown, options);
|
||||||
|
|
||||||
await axios
|
await axios
|
||||||
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -161,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
|
|||||||
.then((response) => response.data.id);
|
.then((response) => response.data.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Make patch changes to the objects in the db
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function patchData(patchDb: Partial<DatabaseModel>) {
|
||||||
|
const response = await axios.patch(`${ontimeURL}/db`, patchDb);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostPreviewExcelResponse = {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
project: ProjectData;
|
||||||
|
userFields: UserFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Make patch changes to the objects in the db
|
||||||
|
* @return {Promise} - returns parsed rundown and userfields
|
||||||
|
*/
|
||||||
|
export async function postPreviewExcel(file: File, setProgress: (value: number) => void, options?: ExcelImportMap) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('userFile', file);
|
||||||
|
formData.append('options', JSON.stringify(options));
|
||||||
|
|
||||||
|
const response: AxiosResponse<PostPreviewExcelResponse> = await axios.post(
|
||||||
|
`${ontimeURL}/preview-spreadsheet`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
const complete = progressEvent?.total ? Math.round((progressEvent.loaded * 100) / progressEvent.total) : 0;
|
||||||
|
setProgress(complete);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
export type HasUpdate = {
|
export type HasUpdate = {
|
||||||
url: string;
|
url: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
|
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
|
||||||
|
|
||||||
import Swatch from './Swatch';
|
import Swatch from './Swatch';
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
|
|||||||
|
|
||||||
interface ColourInputProps {
|
interface ColourInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
name: TitleActions;
|
name: EditorUpdateFields;
|
||||||
handleChange: (newValue: TitleActions, name: string) => void;
|
handleChange: (newValue: EditorUpdateFields, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const colours = [
|
const colours = [
|
||||||
|
|||||||
@@ -38,6 +38,12 @@
|
|||||||
gap: $element-inner-spacing;
|
gap: $element-inner-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.noHover {
|
||||||
|
&:hover {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
|
|||||||
import { Textarea } from '@chakra-ui/react';
|
import { Textarea } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||||
|
import { EditorUpdateFields } from '../EventEditor';
|
||||||
import { TitleActions } from './EventEditorDataLeft';
|
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
interface CountedTextAreaProps {
|
interface CountedTextAreaProps {
|
||||||
field: TitleActions;
|
field: EditorUpdateFields;
|
||||||
label: string;
|
label: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
submitHandler: (field: TitleActions, value: string) => void;
|
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CountedTextArea(props: CountedTextAreaProps) {
|
export default function CountedTextArea(props: CountedTextAreaProps) {
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
.title {
|
.title {
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $gray-500;
|
color: $gray-500;
|
||||||
padding-left: 8px;
|
padding-left: 0.5rem;
|
||||||
margin: 8px 0;
|
margin: 0.5rem 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
color: $error-red;
|
color: $error-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
@include subsection;
|
||||||
|
color: $action-blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedbackSection {
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
.buttonSection {
|
.buttonSection {
|
||||||
margin-top: $section-spacing;
|
margin-top: $section-spacing;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vSpacer {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shiftRight {
|
.shiftRight {
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
}
|
}
|
||||||
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoEqualColumn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.padBottom {
|
.padBottom {
|
||||||
padding-bottom: $element-spacing;
|
padding-bottom: $element-spacing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ChangeEvent, useRef, useState } from 'react';
|
||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import UploadEntry from './upload-entry/UploadEntry';
|
||||||
|
import { useUploadModalContextStore } from './uploadModalContext';
|
||||||
|
import { validateFile } from './uploadUtils';
|
||||||
|
|
||||||
|
import style from './UploadModal.module.scss';
|
||||||
|
|
||||||
|
export default function UploadFile() {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { file, setFile, progress } = useUploadModalContextStore();
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<string | undefined>();
|
||||||
|
const success = false;
|
||||||
|
|
||||||
|
const clearFile = () => {
|
||||||
|
setFile(null);
|
||||||
|
setErrors('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setErrors('');
|
||||||
|
|
||||||
|
const selectedFile = event?.target?.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
validateFile(selectedFile);
|
||||||
|
setFile(selectedFile);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setErrors(error.message);
|
||||||
|
}
|
||||||
|
setFile(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleFile}
|
||||||
|
accept='.json, .xlsx'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
{!file && (
|
||||||
|
<div className={style.uploadArea} onClick={handleClick}>
|
||||||
|
Click to upload Ontime project or xlsx file
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(file || errors) && (
|
||||||
|
<UploadEntry file={file} errors={errors} progress={progress} success={success} handleClear={clearFile} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,79 +5,31 @@
|
|||||||
.uploadBody {
|
.uploadBody {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploadArea {
|
.uploadArea {
|
||||||
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 200px;
|
max-width: 550px;
|
||||||
border: 2px dashed $gray-50;
|
|
||||||
|
min-height: 150px;
|
||||||
|
border: 2px dashed $gray-200;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
transition-property: background-color;
|
transition-property: background-color;
|
||||||
transition-duration: $transition-time-action;
|
transition-duration: $transition-time-action;
|
||||||
|
font-size: calc(1rem - 1px);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 2px solid $blue-500;
|
border: 2px solid $blue-500;
|
||||||
background-color: $blue-50;
|
background-color: $blue-50;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.comment {
|
&.comment {
|
||||||
color: gray;
|
color: $modal-note-color;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.uploadedItem {
|
|
||||||
background-color: $gray-50;
|
|
||||||
padding: 8px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-areas:
|
|
||||||
"icon title close"
|
|
||||||
"icon info ."
|
|
||||||
"progress progress progress";
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
column-gap: 16px;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
align-self: center;
|
|
||||||
grid-area: icon;
|
|
||||||
font-size: 32px;
|
|
||||||
color: $gray-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileTitle {
|
|
||||||
grid-area: title;
|
|
||||||
font-size: 14px;
|
|
||||||
color: $gray-1350;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileInfo {
|
|
||||||
grid-area: info;
|
|
||||||
font-size: 12px;
|
|
||||||
color: $gray-1100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fileProgress {
|
|
||||||
grid-area: progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cancelUpload {
|
|
||||||
grid-area: close;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
.icon {
|
|
||||||
color: $error-red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
.icon {
|
|
||||||
color: $green-500;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,3 +41,9 @@
|
|||||||
.pad {
|
.pad {
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.twoColumn {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
@@ -9,22 +8,33 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalOverlay,
|
ModalOverlay,
|
||||||
Progress,
|
|
||||||
Switch,
|
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
|
||||||
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
|
||||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
|
||||||
import { uploadData } from '../../../common/api/ontimeApi';
|
import {
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
patchData,
|
||||||
import ModalSplitInput from '../ModalSplitInput';
|
postPreviewExcel,
|
||||||
|
ProjectFileImportOptions,
|
||||||
|
uploadProjectFile,
|
||||||
|
} from '../../../common/api/ontimeApi';
|
||||||
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
|
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||||
|
|
||||||
import { validateFile } from './utils';
|
import PreviewExcel from './preview/PreviewExcel';
|
||||||
|
import ExcelFileOptions from './upload-options/ExcelFileOptions';
|
||||||
|
import OntimeFileOptions from './upload-options/OntimeFileOptions';
|
||||||
|
import UploadStepTracker from './upload-step/UploadStep';
|
||||||
|
import UploadFile from './UploadFile';
|
||||||
|
import { useUploadModalContextStore } from './uploadModalContext';
|
||||||
|
import { isExcelFile, isOntimeFile } from './uploadUtils';
|
||||||
|
|
||||||
import style from './UploadModal.module.scss';
|
import style from './UploadModal.module.scss';
|
||||||
|
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
|
||||||
|
|
||||||
|
export type UploadStep = 'upload' | 'review';
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -33,64 +43,130 @@ interface UploadModalProps {
|
|||||||
|
|
||||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { emitError } = useEmitLog();
|
|
||||||
const [errors, setErrors] = useState<string | undefined>();
|
|
||||||
const [isSubmitting, setSubmitting] = useState(false);
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const overrideOptionRef = useRef<HTMLInputElement>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
|
|
||||||
const handleFile = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
const { file, setProgress, clear } = useUploadModalContextStore();
|
||||||
const fileUploaded = event?.target?.files?.[0];
|
|
||||||
if (!fileUploaded) return;
|
|
||||||
|
|
||||||
const validate = validateFile(fileUploaded);
|
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
|
||||||
setErrors(validate.errors?.[0]);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
|
||||||
|
const [userFields, setUserFields] = useState<UserFields | null>(null);
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
if (validate.isValid) {
|
const [errors, setErrors] = useState('');
|
||||||
setFile(fileUploaded);
|
|
||||||
} else {
|
|
||||||
setFile(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
|
||||||
setSubmitting(true);
|
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
|
||||||
|
|
||||||
|
/* if the modal re-opens, we want to restart all states */
|
||||||
|
useEffect(() => {
|
||||||
|
clear();
|
||||||
|
setUploadStep('upload');
|
||||||
|
setSubmitting(false);
|
||||||
|
setRundown(null);
|
||||||
|
setUserFields(null);
|
||||||
|
setProject(null);
|
||||||
|
setErrors('');
|
||||||
|
}, [clear, isOpen]);
|
||||||
|
|
||||||
|
/* uploads file to backend
|
||||||
|
* - in the case of excel, we get the preview
|
||||||
|
* - in the case of project file, this is end of line
|
||||||
|
**/
|
||||||
|
const handleUpload = async () => {
|
||||||
|
let doClose = false;
|
||||||
if (file) {
|
if (file) {
|
||||||
|
setSubmitting(true);
|
||||||
|
setErrors('');
|
||||||
try {
|
try {
|
||||||
const options = {
|
if (isOntimeFile(file)) {
|
||||||
onlyRundown: overrideOptionRef.current?.checked || false,
|
// TODO: we would also like to have preview for ontime project files
|
||||||
};
|
const options = ontimeFileOptions.current;
|
||||||
await uploadData(file, setProgress, options);
|
await handleOntimeFile(file, options);
|
||||||
|
doClose = true;
|
||||||
|
} else if (isExcelFile(file)) {
|
||||||
|
const options = excelFileOptions.current;
|
||||||
|
await handleExcelFile(file, options);
|
||||||
|
await invalidateAllCaches();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emitError(`Failed uploading file: ${error}`);
|
const message = maybeAxiosError(error);
|
||||||
|
setErrors(`Failed uploading file ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
setSubmitting(false);
|
||||||
setSuccess(true);
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSubmitting(false);
|
|
||||||
}, [emitError, file, queryClient]);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
// when we upload excel, we populate state with preview data
|
||||||
fileInputRef.current?.click();
|
async function handleExcelFile(file: File, options: ExcelImportMap) {
|
||||||
};
|
const response = await postPreviewExcel(file, setProgress, options);
|
||||||
|
if (response.status === 200) {
|
||||||
const clearFile = () => {
|
setRundown(response.data.rundown);
|
||||||
setFile(null);
|
setUserFields(response.data.userFields);
|
||||||
|
setProject(response.data.project);
|
||||||
|
// in excel imports we have an extra review step
|
||||||
|
setUploadStep('review');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// when we upload project files, no extra operations are done
|
||||||
|
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
|
||||||
|
await uploadProjectFile(file, setProgress, options);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// before closing the modal, we clear data from mutations
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
clearFile();
|
clear();
|
||||||
setSuccess(false);
|
setRundown([]);
|
||||||
setErrors(undefined);
|
setUserFields(userFieldsPlaceholder);
|
||||||
setProgress(0);
|
setProject(projectDataPlaceholder);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const disableSubmit = !file || isSubmitting;
|
const handleFinalise = async () => {
|
||||||
|
// this step is currently only used for excel files, after preview
|
||||||
|
if (isExcel && rundown && userFields && project) {
|
||||||
|
let doClose = false;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await patchData({ rundown, userFields, project });
|
||||||
|
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
|
||||||
|
queryClient.setQueryData(USERFIELDS, userFields);
|
||||||
|
queryClient.setQueryData(PROJECT_DATA, project);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
|
||||||
|
});
|
||||||
|
doClose = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setErrors(`Failed applying changes ${message}`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undoReview = () => {
|
||||||
|
setUploadStep('upload');
|
||||||
|
setErrors('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpload = uploadStep === 'upload';
|
||||||
|
const isReview = uploadStep === 'review';
|
||||||
|
const isExcel = isExcelFile(file);
|
||||||
|
const isOntime = isOntimeFile(file);
|
||||||
|
|
||||||
|
const handleGoBack = isUpload ? undefined : undoReview;
|
||||||
|
const handleSubmit = isUpload ? handleUpload : handleFinalise;
|
||||||
|
const disableSubmit = (isUpload && !file) || (isReview && rundown === null);
|
||||||
|
const disableGoBack = isUpload;
|
||||||
|
const submitText = isUpload ? 'Upload' : 'Finish';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -101,66 +177,51 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
|||||||
size='xl'
|
size='xl'
|
||||||
scrollBehavior='inside'
|
scrollBehavior='inside'
|
||||||
preserveScrollBarGap
|
preserveScrollBarGap
|
||||||
variant='ontime-small'
|
variant='ontime-upload'
|
||||||
>
|
>
|
||||||
<ModalOverlay />
|
<ModalOverlay />
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
<ModalHeader>File import</ModalHeader>
|
<ModalHeader>File import</ModalHeader>
|
||||||
<ModalCloseButton />
|
<ModalCloseButton />
|
||||||
<ModalBody className={style.uploadBody}>
|
<ModalBody className={style.uploadBody}>
|
||||||
<Input
|
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
|
||||||
ref={fileInputRef}
|
{uploadStep === 'upload' ? (
|
||||||
style={{ display: 'none' }}
|
<>
|
||||||
type='file'
|
<UploadFile />
|
||||||
onChange={handleFile}
|
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
|
||||||
accept='.json, .xlsx'
|
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
|
||||||
data-testid='file-input'
|
</>
|
||||||
/>
|
) : (
|
||||||
<div className={style.uploadArea} onClick={handleClick}>
|
<PreviewExcel
|
||||||
Click to upload Ontime project file
|
rundown={rundown ?? []}
|
||||||
</div>
|
project={project ?? projectDataPlaceholder}
|
||||||
{file && (
|
userFields={userFields ?? userFieldsPlaceholder}
|
||||||
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
|
/>
|
||||||
<IoClose className={style.cancelUpload} onClick={clearFile} />
|
|
||||||
<IoDocumentTextOutline className={style.icon} />
|
|
||||||
<span className={style.fileTitle}>{file.name}</span>
|
|
||||||
<span className={style.fileInfo}>{`${(file.size / 1024).toFixed(2)}kb - ${file.type}`}</span>
|
|
||||||
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{errors && (
|
|
||||||
<div className={`${style.uploadedItem} ${style.error}`}>
|
|
||||||
<IoWarningOutline className={style.icon} />
|
|
||||||
<span className={style.fileTitle}>{errors}</span>
|
|
||||||
<span className={style.fileInfo}>Please try again</span>
|
|
||||||
<Progress className={style.fileProgress} value={progress} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={style.uploadOptions}>
|
|
||||||
<span className={style.title}>Import options</span>
|
|
||||||
<ModalSplitInput
|
|
||||||
field=''
|
|
||||||
title='Only import rundown'
|
|
||||||
description='All other options, including application settings will be discarded'
|
|
||||||
>
|
|
||||||
<Switch variant='ontime-on-light' ref={overrideOptionRef} />
|
|
||||||
</ModalSplitInput>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
|
<ModalFooter>
|
||||||
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
|
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
|
||||||
Cancel
|
|
||||||
</Button>
|
<div className={`${style.buttonSection} ${style.pad}`}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleGoBack}
|
||||||
isLoading={isSubmitting}
|
isDisabled={disableGoBack || submitting}
|
||||||
isDisabled={disableSubmit}
|
variant='ontime-ghost-on-light'
|
||||||
variant='ontime-filled'
|
size='sm'
|
||||||
padding='0 2em'
|
>
|
||||||
size='sm'
|
Go Back
|
||||||
>
|
</Button>
|
||||||
Import
|
<Button
|
||||||
</Button>
|
onClick={handleSubmit}
|
||||||
|
isLoading={submitting}
|
||||||
|
isDisabled={disableSubmit}
|
||||||
|
variant='ontime-filled'
|
||||||
|
padding='0 2em'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
{submitText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
@mixin pad-item {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewTable {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: repeat(6, auto);
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
font-weight: 200;
|
||||||
|
@include pad-item;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
@include pad-item;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
|
||||||
|
import PreviewProjectData from './PreviewProjectData';
|
||||||
|
import PreviewRundown from './PreviewRundown';
|
||||||
|
|
||||||
|
import style from '../../Modal.module.scss';
|
||||||
|
|
||||||
|
interface PreviewExcelProps {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
project: ProjectData;
|
||||||
|
userFields: UserFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewExcel(props: PreviewExcelProps) {
|
||||||
|
const { rundown, project, userFields } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.column} ${style.noHover}`}>
|
||||||
|
<div className={style.title}>Review Project Data</div>
|
||||||
|
<PreviewProjectData project={project} />
|
||||||
|
<div className={style.vSpacer} />
|
||||||
|
<div className={style.title}>Review Rundown</div>
|
||||||
|
<PreviewRundown rundown={rundown} userFields={userFields} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
|
import style from './PreviewColumn.module.scss';
|
||||||
|
|
||||||
|
interface PreviewProjectDataProps {
|
||||||
|
project: ProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewProjectData({ project }: PreviewProjectDataProps) {
|
||||||
|
return (
|
||||||
|
<div className={style.previewTable}>
|
||||||
|
<span className={style.field}>Title</span>
|
||||||
|
<span className={style.value}>{project.title}</span>
|
||||||
|
<span className={style.field}>Description</span>
|
||||||
|
<span className={style.value}>{project.description}</span>
|
||||||
|
<span className={style.field}>Public URL</span>
|
||||||
|
<span className={style.value}>{project.publicUrl}</span>
|
||||||
|
<span className={style.field}>Public info</span>
|
||||||
|
<span className={style.value}>{project.publicInfo}</span>
|
||||||
|
<span className={style.field}>Backstage URL</span>
|
||||||
|
<span className={style.value}>{project.backstageUrl}</span>
|
||||||
|
<span className={style.field}>Backstage info</span>
|
||||||
|
<span className={style.value}>{project.backstageInfo}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { isOntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
|
||||||
|
|
||||||
|
import Tag from './Tag';
|
||||||
|
|
||||||
|
import style from './PreviewTable.module.scss';
|
||||||
|
|
||||||
|
interface PreviewRundownProps {
|
||||||
|
rundown: OntimeRundown;
|
||||||
|
userFields: UserFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanToText(value?: boolean) {
|
||||||
|
return value ? 'Yes' : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewRundown({ rundown, userFields }: PreviewRundownProps) {
|
||||||
|
return (
|
||||||
|
<div className={style.container}>
|
||||||
|
<div className={style.scrollContainer}>
|
||||||
|
<table className={style.rundownPreview}>
|
||||||
|
<thead className={style.header}>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Cue</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Subtitle</th>
|
||||||
|
<th>Presenter</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th>Time Start</th>
|
||||||
|
<th>Time End</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Is Public</th>
|
||||||
|
<th>Skip</th>
|
||||||
|
<th>Colour</th>
|
||||||
|
<th>Timer Type</th>
|
||||||
|
<th>End Action</th>
|
||||||
|
<th>
|
||||||
|
user0 <Tag>{userFields.user0}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user1 <Tag>{userFields.user1}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user2 <Tag>{userFields.user2}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user3 <Tag>{userFields.user3}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user4 <Tag>{userFields.user4}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user5 <Tag>{userFields.user5}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user6 <Tag>{userFields.user6}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user7 <Tag>{userFields.user7}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user8 <Tag>{userFields.user8}</Tag>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
user9 <Tag>{userFields.user9}</Tag>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={style.body}>
|
||||||
|
{rundown.map((event, index) => {
|
||||||
|
const key = event.id;
|
||||||
|
if (isOntimeEvent(event)) {
|
||||||
|
const colour = event.colour ? getAccessibleColour(event.colour) : {};
|
||||||
|
const isPublic = booleanToText(event.isPublic);
|
||||||
|
const skip = booleanToText(event.skip);
|
||||||
|
return (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>{index + 1}</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.center}>
|
||||||
|
<Tag>Event</Tag>
|
||||||
|
</td>
|
||||||
|
<td className={style.nowrap}>{event.cue}</td>
|
||||||
|
<td>{event.title}</td>
|
||||||
|
<td>{event.subtitle}</td>
|
||||||
|
<td>{event.presenter}</td>
|
||||||
|
<td>{event.note}</td>
|
||||||
|
<td>{millisToString(event.timeStart)}</td>
|
||||||
|
<td>{millisToString(event.timeEnd)}</td>
|
||||||
|
<td>{millisToString(event.duration)}</td>
|
||||||
|
<td>{isPublic && <Tag>{isPublic}</Tag>}</td>
|
||||||
|
<td>{skip && <Tag>{skip}</Tag>}</td>
|
||||||
|
<td style={{ ...colour }}>{event.colour}</td>
|
||||||
|
<td>
|
||||||
|
<Tag>{event.timerType}</Tag>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Tag>{event.endAction}</Tag>
|
||||||
|
</td>
|
||||||
|
<td>{event.user0}</td>
|
||||||
|
<td>{event.user1}</td>
|
||||||
|
<td>{event.user2}</td>
|
||||||
|
<td>{event.user3}</td>
|
||||||
|
<td>{event.user4}</td>
|
||||||
|
<td>{event.user5}</td>
|
||||||
|
<td>{event.user6}</td>
|
||||||
|
<td>{event.user7}</td>
|
||||||
|
<td>{event.user8}</td>
|
||||||
|
<td>{event.user9}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: max(300px, 30vh);
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollContainer {
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rundownPreview {
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
border-collapse: separate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.body {
|
||||||
|
th {
|
||||||
|
font-weight: 400;
|
||||||
|
height: unset;
|
||||||
|
line-height: calc(1rem - 2px);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
th {
|
||||||
|
font-weight: 200;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
word-wrap: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
tr:nth-child(odd) {
|
||||||
|
background-color: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@use "../../../../theme/_ontimeColours" as *;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-size: 10px;
|
||||||
|
background-color: $blue-500;
|
||||||
|
color: $pure-white;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import style from './Tag.module.scss';
|
||||||
|
|
||||||
|
export default function Tag({ children }: { children: ReactNode }) {
|
||||||
|
return <span className={style.tag}>{children}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
@use '../../../../theme/ontimeColours' as *;
|
||||||
|
@use '../../../../theme/v2Styles' as *;
|
||||||
|
|
||||||
|
.uploadedItem {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 550px;
|
||||||
|
|
||||||
|
border: 1px solid $gray-200;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
"icon title close"
|
||||||
|
"icon info ."
|
||||||
|
"progress progress progress";
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
column-gap: 1rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
align-self: center;
|
||||||
|
grid-area: icon;
|
||||||
|
font-size: 2rem;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileTitle {
|
||||||
|
grid-area: title;
|
||||||
|
font-size: calc(1rem - 2px);
|
||||||
|
color: $ui-black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInfo {
|
||||||
|
grid-area: info;
|
||||||
|
font-size: calc(1rem - 4px);
|
||||||
|
color: $gray-1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileProgress {
|
||||||
|
grid-area: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelUpload {
|
||||||
|
grid-area: close;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
.icon {
|
||||||
|
color: $error-red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
.icon {
|
||||||
|
color: $green-500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Progress } from '@chakra-ui/react';
|
||||||
|
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||||
|
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
||||||
|
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||||
|
|
||||||
|
import { isExcelFile, isOntimeFile } from '../uploadUtils';
|
||||||
|
|
||||||
|
import style from './UploadEntry.module.scss';
|
||||||
|
|
||||||
|
interface UploadEntryProps {
|
||||||
|
file: File | null;
|
||||||
|
errors?: string;
|
||||||
|
progress: number;
|
||||||
|
success: boolean;
|
||||||
|
handleClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadEntry(props: UploadEntryProps) {
|
||||||
|
const { file, errors, progress, success, handleClear } = props;
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
return (
|
||||||
|
<div className={`${style.uploadedItem} ${style.error}`}>
|
||||||
|
<IoClose className={style.cancelUpload} onClick={handleClear} />
|
||||||
|
<IoWarningOutline className={style.icon} />
|
||||||
|
<span className={style.fileTitle}>{errors}</span>
|
||||||
|
<span className={style.fileInfo}>Please try again</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const fileSize = `${(file.size / 1024).toFixed(2)}kb`;
|
||||||
|
let fileType = '';
|
||||||
|
if (isOntimeFile(file)) {
|
||||||
|
fileType = 'Ontime Project File';
|
||||||
|
} else if (isExcelFile(file)) {
|
||||||
|
fileType = 'Excel Rundown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${style.uploadedItem} ${success ? style.success : ''}`}>
|
||||||
|
<IoClose className={style.cancelUpload} onClick={handleClear} />
|
||||||
|
<IoDocumentTextOutline className={style.icon} />
|
||||||
|
<span className={style.fileTitle}>{file.name}</span>
|
||||||
|
<span className={style.fileInfo}>{`${fileSize} - ${fileType}`}</span>
|
||||||
|
<Progress variant='ontime-on-light' className={style.fileProgress} value={progress} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { MutableRefObject } from 'react';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import ImportMapTable, { type TableEntry } from './ImportMapTable';
|
||||||
|
|
||||||
|
import style from '../UploadModal.module.scss';
|
||||||
|
|
||||||
|
interface ExcelFileOptionsProps {
|
||||||
|
optionsRef: MutableRefObject<ExcelImportMap>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExcelFileOptions(props: ExcelFileOptionsProps) {
|
||||||
|
const { optionsRef } = props;
|
||||||
|
|
||||||
|
const updateRef = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
|
||||||
|
// avoid unnecessary changes
|
||||||
|
if (optionsRef.current[field] !== value) {
|
||||||
|
optionsRef.current = { ...optionsRef.current, [field]: value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const worksheet: TableEntry[] = [{ label: 'Worksheet', title: 'worksheet', value: optionsRef.current.worksheet }];
|
||||||
|
|
||||||
|
const timings: TableEntry[] = [
|
||||||
|
{ label: 'Start time', title: 'timeStart', value: optionsRef.current.timeStart },
|
||||||
|
{ label: 'End Time', title: 'timeEnd', value: optionsRef.current.timeEnd },
|
||||||
|
{ label: 'Duration', title: 'duration', value: optionsRef.current.duration },
|
||||||
|
];
|
||||||
|
|
||||||
|
const titles: TableEntry[] = [
|
||||||
|
{ label: 'Cue', title: 'cue', value: optionsRef.current.cue },
|
||||||
|
{ label: 'Colour', title: 'colour', value: optionsRef.current.colour },
|
||||||
|
{ label: 'Title', title: 'title', value: optionsRef.current.title },
|
||||||
|
{ label: 'Presenter', title: 'presenter', value: optionsRef.current.presenter },
|
||||||
|
{ label: 'Subtitle', title: 'subtitle', value: optionsRef.current.subtitle },
|
||||||
|
{ label: 'Note', title: 'note', value: optionsRef.current.note },
|
||||||
|
];
|
||||||
|
|
||||||
|
const options: TableEntry[] = [
|
||||||
|
{ label: 'Is Public', title: 'isPublic', value: optionsRef.current.isPublic },
|
||||||
|
{ label: 'Skip', title: 'skip', value: optionsRef.current.skip },
|
||||||
|
{ label: 'Timer Type', title: 'timerType', value: optionsRef.current.timerType },
|
||||||
|
{ label: 'End Action', title: 'endAction', value: optionsRef.current.endAction },
|
||||||
|
];
|
||||||
|
|
||||||
|
const userFields: TableEntry[] = [
|
||||||
|
{ label: 'User 0', title: 'user0', value: optionsRef.current.user0 },
|
||||||
|
{ label: 'User 1', title: 'user1', value: optionsRef.current.user1 },
|
||||||
|
{ label: 'User 2', title: 'user2', value: optionsRef.current.user2 },
|
||||||
|
{ label: 'User 3', title: 'user3', value: optionsRef.current.user3 },
|
||||||
|
{ label: 'User 4', title: 'user4', value: optionsRef.current.user4 },
|
||||||
|
{ label: 'User 5', title: 'user5', value: optionsRef.current.user5 },
|
||||||
|
{ label: 'User 6', title: 'user6', value: optionsRef.current.user6 },
|
||||||
|
{ label: 'User 7', title: 'user7', value: optionsRef.current.user7 },
|
||||||
|
{ label: 'User 8', title: 'user8', value: optionsRef.current.user8 },
|
||||||
|
{ label: 'User 9', title: 'user9', value: optionsRef.current.user9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.uploadOptions}>
|
||||||
|
<div className={style.twoEqualColumn}>
|
||||||
|
<ImportMapTable title='Import options' fields={worksheet} handleOnChange={updateRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.twoEqualColumn}>
|
||||||
|
<ImportMapTable title='Timings' fields={timings} handleOnChange={updateRef} />
|
||||||
|
<ImportMapTable title='Options' fields={options} handleOnChange={updateRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={style.twoEqualColumn}>
|
||||||
|
<ImportMapTable title='Titles' fields={titles} handleOnChange={updateRef} />
|
||||||
|
<ImportMapTable title='User Fields' fields={userFields} handleOnChange={updateRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
@use '../../../../theme/v2Styles' as *;
|
||||||
|
@use '../../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.importTable {
|
||||||
|
margin: 0.5rem;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
thead {
|
||||||
|
color: $gray-500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 10em;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: $gray-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
td {
|
||||||
|
max-width: fit-content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 6em;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Input } from '@chakra-ui/react';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import style from './ImportMapTable.module.scss';
|
||||||
|
|
||||||
|
export type TableEntry = { label: string; title: keyof ExcelImportMap; value: string };
|
||||||
|
|
||||||
|
interface ImportMapTableProps {
|
||||||
|
title: string;
|
||||||
|
fields: TableEntry[];
|
||||||
|
handleOnChange: (field: keyof ExcelImportMap, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportMapTable(props: ImportMapTableProps) {
|
||||||
|
const { title, fields, handleOnChange } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className={style.importTable}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2}>{title}</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{fields.map((field) => {
|
||||||
|
return (
|
||||||
|
<tr key={field.title}>
|
||||||
|
<td className={style.label}>
|
||||||
|
<label htmlFor={field.title}>{field.title}</label>
|
||||||
|
</td>
|
||||||
|
<td className={style.input}>
|
||||||
|
<Input
|
||||||
|
id={field.title}
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
maxLength={25}
|
||||||
|
defaultValue={field.value}
|
||||||
|
placeholder='Use default column name'
|
||||||
|
onBlur={(event) => {
|
||||||
|
handleOnChange(field.title, event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MutableRefObject } from 'react';
|
||||||
|
import { Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
|
import { ProjectFileImportOptions } from '../../../../common/api/ontimeApi';
|
||||||
|
import ModalSplitInput from '../../ModalSplitInput';
|
||||||
|
|
||||||
|
import style from '../UploadModal.module.scss';
|
||||||
|
|
||||||
|
interface OntimeFileOptionsProps {
|
||||||
|
optionsRef: MutableRefObject<Partial<ProjectFileImportOptions>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OntimeFileOptions(props: OntimeFileOptionsProps) {
|
||||||
|
const { optionsRef } = props;
|
||||||
|
|
||||||
|
const updateRef = <T extends keyof ProjectFileImportOptions>(field: T, value: ProjectFileImportOptions[T]) => {
|
||||||
|
optionsRef.current = { ...optionsRef.current, [field]: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.uploadOptions}>
|
||||||
|
<span className={style.title}>Import options</span>
|
||||||
|
<ModalSplitInput field='' title='Only import rundown' description='All other project options will be kept'>
|
||||||
|
<Switch
|
||||||
|
variant='ontime-on-light'
|
||||||
|
onChange={(e) => {
|
||||||
|
updateRef('onlyRundown', e.target.checked);
|
||||||
|
}}
|
||||||
|
defaultChecked={Boolean(optionsRef.current.onlyRundown)}
|
||||||
|
/>
|
||||||
|
</ModalSplitInput>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@use '../../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
@mixin row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stepRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idle {
|
||||||
|
@include row;
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive {
|
||||||
|
@include row;
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
@include row;
|
||||||
|
color: $blue-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.inactiveIcon {
|
||||||
|
color: $gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeIcon {
|
||||||
|
color: $blue-500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
||||||
|
import { IoChevronForward } from '@react-icons/all-files/io5/IoChevronForward';
|
||||||
|
import { IoEllipseOutline } from '@react-icons/all-files/io5/IoEllipseOutline';
|
||||||
|
|
||||||
|
import type { UploadStep } from '../UploadModal';
|
||||||
|
|
||||||
|
import style from './UploadStep.module.scss';
|
||||||
|
|
||||||
|
export default function UploadStepTracker({ uploadStep }: { uploadStep: UploadStep }) {
|
||||||
|
const isUpload = uploadStep === 'upload';
|
||||||
|
const isReview = uploadStep === 'review';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.stepRow}>
|
||||||
|
<div className={isUpload ? style.active : style.idle}>
|
||||||
|
<IoCheckmarkCircle />
|
||||||
|
Upload
|
||||||
|
</div>
|
||||||
|
<IoChevronForward className={isReview ? style.activeIcon : style.inactiveIcon} />
|
||||||
|
<div className={isReview ? style.active : style.inactive}>
|
||||||
|
{isReview ? <IoCheckmarkCircle /> : <IoEllipseOutline />}
|
||||||
|
Review
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type UploadModalContext = {
|
||||||
|
file: File | null;
|
||||||
|
setFile: (file: File | null) => void;
|
||||||
|
|
||||||
|
progress: number;
|
||||||
|
setProgress: (progress: number) => void;
|
||||||
|
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUploadModalContextStore = create<UploadModalContext>((set) => ({
|
||||||
|
file: null,
|
||||||
|
setFile: (file: File | null) => set({ file }),
|
||||||
|
|
||||||
|
progress: 0,
|
||||||
|
setProgress: (progress: number) => set({ progress }),
|
||||||
|
|
||||||
|
clear: () => set({ file: null, progress: 0 }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export function validateFile(file: File) {
|
||||||
|
if (!file) {
|
||||||
|
throw new Error('No file to upload');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit file size of a project file to around 1MB
|
||||||
|
if (file.name.endsWith('.json') && file.size > 1_000_000) {
|
||||||
|
throw new Error('File size limit (1MB) exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit file size of an excel file to around 10MB
|
||||||
|
if (file.name.endsWith('.xlsx') && file.size > 10_000_000) {
|
||||||
|
throw new Error('File size limit (10MB) exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
|
||||||
|
throw new Error('Unhandled file type');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExcelFile(file: File | null) {
|
||||||
|
return file?.name.endsWith('.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOntimeFile(file: File | null) {
|
||||||
|
return file?.name.endsWith('.json');
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
type ValidationStatus = {
|
|
||||||
errors: string[];
|
|
||||||
isValid: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateFile(file: File): ValidationStatus {
|
|
||||||
const status: ValidationStatus = { errors: [], isValid: true };
|
|
||||||
if (!file) {
|
|
||||||
status.errors.push('No file to upload');
|
|
||||||
status.isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit file size to 1MB
|
|
||||||
if (file.size > 1000000) {
|
|
||||||
status.errors.push('File size limit (1MB) exceeded');
|
|
||||||
status.isValid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file extension
|
|
||||||
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.json')) {
|
|
||||||
status.errors.push('Unhandled file type');
|
|
||||||
status.isValid = false;
|
|
||||||
}
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const ontimeProgressGray = {
|
||||||
|
track: {
|
||||||
|
background: '#f6f6f6', // $gray-500
|
||||||
|
},
|
||||||
|
filledTrack: {
|
||||||
|
background: '#578AF4', // $blue-500
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,8 +2,8 @@ export const ontimeModal = {
|
|||||||
header: {
|
header: {
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
letterSpacing: '0.3px',
|
letterSpacing: '0.3px',
|
||||||
padding: '16px 24px',
|
padding: '1rem 1.5rem',
|
||||||
fontSize: '20px',
|
fontSize: '1.25rem',
|
||||||
color: '#202020', // $gray-50
|
color: '#202020', // $gray-50
|
||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
@@ -20,17 +20,29 @@ export const ontimeModal = {
|
|||||||
color: '#202020', // $gray-50
|
color: '#202020', // $gray-50
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
padding: '8px',
|
padding: '0.5rem',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ontimeSmallModal = {
|
export const ontimeSmallModal = {
|
||||||
...ontimeModal,
|
...ontimeModal,
|
||||||
body: {
|
body: {
|
||||||
padding: '16px',
|
padding: '1rem',
|
||||||
fontSize: '14px',
|
fontSize: 'calc(1rem - 2px)',
|
||||||
},
|
},
|
||||||
dialog: {
|
dialog: {
|
||||||
minHeight: 'min(200px, 10vh)',
|
minHeight: 'min(200px, 10vh)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ontimeUploadModal = {
|
||||||
|
...ontimeSmallModal,
|
||||||
|
body: {
|
||||||
|
padding: '1rem',
|
||||||
|
fontSize: 'calc(1rem - 2px)',
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
minHeight: 'min(200px, 10vh)',
|
||||||
|
maxWidth: 'min(800px, 80vh)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
import { ontimeCheckboxOnDark } from './ontimeCheckbox';
|
import { ontimeCheckboxOnDark } from './ontimeCheckbox';
|
||||||
import { ontimeEditable } from './ontimeEditable';
|
import { ontimeEditable } from './ontimeEditable';
|
||||||
import { ontimeMenuOnDark } from './ontimeMenu';
|
import { ontimeMenuOnDark } from './ontimeMenu';
|
||||||
import { ontimeModal, ontimeSmallModal } from './ontimeModal';
|
import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal';
|
||||||
|
import { ontimeProgressGray } from './OntimeProgress';
|
||||||
import { ontimeBlockRadio } from './ontimeRadio';
|
import { ontimeBlockRadio } from './ontimeRadio';
|
||||||
import { ontimeSelect } from './ontimeSelect';
|
import { ontimeSelect } from './ontimeSelect';
|
||||||
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
|
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
|
||||||
@@ -79,6 +80,12 @@ const theme = extendTheme({
|
|||||||
variants: {
|
variants: {
|
||||||
ontime: { ...ontimeModal },
|
ontime: { ...ontimeModal },
|
||||||
'ontime-small': { ...ontimeSmallModal },
|
'ontime-small': { ...ontimeSmallModal },
|
||||||
|
'ontime-upload': { ...ontimeUploadModal },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Progress: {
|
||||||
|
variants: {
|
||||||
|
'ontime-on-light': { ...ontimeProgressGray },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Radio: {
|
Radio: {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"lowdb": "^5.0.5",
|
"lowdb": "^5.0.5",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-osc": "^8.0.10",
|
"node-osc": "^8.0.10",
|
||||||
"node-xlsx": "^0.21.0",
|
"node-xlsx": "^0.23.0",
|
||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"passport": "^0.6.0",
|
"passport": "^0.6.0",
|
||||||
"passport-local": "~1.0.0",
|
"passport-local": "~1.0.0",
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
* Class Event Provider is a mediator for handling the local db
|
* Class Event Provider is a mediator for handling the local db
|
||||||
* and adds logic specific to ontime data
|
* and adds logic specific to ontime data
|
||||||
*/
|
*/
|
||||||
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types';
|
import {
|
||||||
|
ProjectData,
|
||||||
|
OntimeRundown,
|
||||||
|
ViewSettings,
|
||||||
|
DatabaseModel,
|
||||||
|
OSCSettings,
|
||||||
|
UserFields,
|
||||||
|
Alias,
|
||||||
|
Settings,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { data, db } from '../../modules/loadDb.js';
|
import { data, db } from '../../modules/loadDb.js';
|
||||||
import { safeMerge } from './DataProvider.utils.js';
|
import { safeMerge } from './DataProvider.utils.js';
|
||||||
@@ -45,7 +54,7 @@ export class DataProvider {
|
|||||||
return data.settings;
|
return data.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setSettings(newData) {
|
static async setSettings(newData: Settings) {
|
||||||
data.settings = { ...newData };
|
data.settings = { ...newData };
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
@@ -58,7 +67,7 @@ export class DataProvider {
|
|||||||
return data.aliases;
|
return data.aliases;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setAliases(newData) {
|
static async setAliases(newData: Alias[]) {
|
||||||
data.aliases = newData;
|
data.aliases = newData;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
@@ -76,12 +85,12 @@ export class DataProvider {
|
|||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setUserFields(newData) {
|
static async setUserFields(newData: UserFields) {
|
||||||
data.userFields = { ...newData };
|
data.userFields = { ...newData };
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setOsc(newData) {
|
static async setOsc(newData: OSCSettings) {
|
||||||
data.osc = { ...newData };
|
data.osc = { ...newData };
|
||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
@@ -95,7 +104,7 @@ export class DataProvider {
|
|||||||
await db.write();
|
await db.write();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async mergeIntoData(newData) {
|
static async mergeIntoData(newData: Partial<DatabaseModel>) {
|
||||||
const mergedData = safeMerge(data, newData);
|
const mergedData = safeMerge(data, newData);
|
||||||
data.project = mergedData.project;
|
data.project = mergedData.project;
|
||||||
data.settings = mergedData.settings;
|
data.settings = mergedData.settings;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import { DatabaseModel } from 'ontime-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges two data objects
|
* Merges two data objects
|
||||||
* @param {object} existing
|
* @param {object} existing
|
||||||
* @param {object} newData
|
* @param {object} newData
|
||||||
*/
|
*/
|
||||||
export function safeMerge(existing, newData) {
|
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||||
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
|
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
|
||||||
return {
|
return {
|
||||||
...existing,
|
...existing,
|
||||||
rundown: rundown ?? existing.rundown,
|
rundown: rundown ?? existing.rundown,
|
||||||
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
http: { ...existing.http, ...http },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,6 @@ describe('safeMerge', () => {
|
|||||||
onFinish: [],
|
onFinish: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
http: {
|
|
||||||
enabled: true,
|
|
||||||
user: null,
|
|
||||||
pwd: null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
it('returns existing data if new data is not provided', () => {
|
it('returns existing data if new data is not provided', () => {
|
||||||
@@ -188,19 +183,6 @@ describe('safeMerge', () => {
|
|||||||
onFinish: [],
|
onFinish: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
http: {
|
|
||||||
user: null,
|
|
||||||
pwd: null,
|
|
||||||
messages: {
|
|
||||||
onLoad: [],
|
|
||||||
onStart: [],
|
|
||||||
onUpdate: [],
|
|
||||||
onPause: [],
|
|
||||||
onStop: [],
|
|
||||||
onFinish: [],
|
|
||||||
},
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Alias, LogOrigin, ProjectData } from 'ontime-types';
|
import { Alias, DatabaseModel, LogOrigin, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { RequestHandler } from 'express';
|
import { RequestHandler } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -7,13 +7,15 @@ import { networkInterfaces } from 'os';
|
|||||||
import { fileHandler } from '../utils/parser.js';
|
import { fileHandler } from '../utils/parser.js';
|
||||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||||
import { mergeObject } from '../utils/parserUtils.js';
|
|
||||||
import { PlaybackService } from '../services/PlaybackService.js';
|
import { PlaybackService } from '../services/PlaybackService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import { isDocker, resolveDbPath } from '../setup.js';
|
import { isDocker, resolveDbPath } from '../setup.js';
|
||||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
|
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
||||||
|
import { deepmerge } from 'ontime-utils';
|
||||||
|
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
||||||
|
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
|
||||||
|
|
||||||
// Create controller for GET request to '/ontime/poll'
|
// Create controller for GET request to '/ontime/poll'
|
||||||
// Returns data for current state
|
// Returns data for current state
|
||||||
@@ -43,44 +45,43 @@ export const dbDownload = async (req, res) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO: docs
|
||||||
|
// TODO: cleanup usage
|
||||||
/**
|
/**
|
||||||
* handles file upload
|
* Parses a file and returns the result objects
|
||||||
|
* @param file
|
||||||
|
* @param _req
|
||||||
|
* @param _res
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
async function parseFile(file, _req, _res, options) {
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
const result = await fileHandler(file, options);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* parse an uploaded file and apply its parsed objects
|
||||||
* @param file
|
* @param file
|
||||||
* @param req
|
* @param req
|
||||||
* @param res
|
* @param res
|
||||||
* @param [options]
|
* @param [options]
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const uploadAndParse = async (file, req, res, options) => {
|
const parseAndApply = async (file, _req, res, options) => {
|
||||||
if (!fs.existsSync(file)) {
|
const result = await parseFile(file, _req, res, options);
|
||||||
res.status(500).send({ message: 'Upload failed' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
PlaybackService.stop();
|
||||||
const result = await fileHandler(file);
|
|
||||||
|
|
||||||
if ('error' in result && result.error) {
|
const newRundown = result.rundown || [];
|
||||||
res.status(400).send({ message: result.message });
|
if (options?.onlyRundown === 'true') {
|
||||||
} else if ('data' in result && result.message === 'success') {
|
await DataProvider.setRundown(newRundown);
|
||||||
PlaybackService.stop();
|
} else {
|
||||||
// explicitly write objects
|
await DataProvider.mergeIntoData(result);
|
||||||
if (typeof result !== 'undefined') {
|
|
||||||
const newRundown = result.data.rundown || [];
|
|
||||||
if (options?.onlyRundown === 'true') {
|
|
||||||
await DataProvider.setRundown(newRundown);
|
|
||||||
} else {
|
|
||||||
await DataProvider.mergeIntoData(result.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
forceReset();
|
|
||||||
res.sendStatus(200);
|
|
||||||
} else {
|
|
||||||
res.status(400).send({ message: 'Failed parsing, no data' });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
|
||||||
}
|
}
|
||||||
|
notifyChanges({ timer: true, external: true, reset: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +170,7 @@ export const postUserFields = async (req, res) => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const persistedData = DataProvider.getUserFields();
|
const persistedData = DataProvider.getUserFields();
|
||||||
const newData = mergeObject(persistedData, req.body);
|
const newData = deepmerge(persistedData, req.body);
|
||||||
await DataProvider.setUserFields(newData);
|
await DataProvider.setUserFields(newData);
|
||||||
res.status(200).send(newData);
|
res.status(200).send(newData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -319,8 +320,38 @@ export const postOSC = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create controller for POST request to '/ontime/db'
|
export async function patchPartialProjectFile(req, res) {
|
||||||
// Returns -
|
if (failEmptyObjects(req.body, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const patchDb: Partial<DatabaseModel> = {
|
||||||
|
project: req.body?.project,
|
||||||
|
settings: req.body?.settings,
|
||||||
|
viewSettings: req.body?.viewSettings,
|
||||||
|
osc: req.body?.osc,
|
||||||
|
aliases: req.body?.aliases,
|
||||||
|
userFields: req.body?.userFields,
|
||||||
|
rundown: req.body?.rundown,
|
||||||
|
};
|
||||||
|
|
||||||
|
await DataProvider.mergeIntoData(patchDb);
|
||||||
|
if (patchDb.rundown !== undefined) {
|
||||||
|
// it is likely cheaper to invalidate cache than to calculate diff
|
||||||
|
PlaybackService.stop();
|
||||||
|
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||||
|
notifyChanges({ external: true, reset: true });
|
||||||
|
}
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uploads and parses a given file
|
||||||
|
*/
|
||||||
export const dbUpload = async (req, res) => {
|
export const dbUpload = async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
res.status(400).send({ message: 'File not found' });
|
res.status(400).send({ message: 'File not found' });
|
||||||
@@ -328,10 +359,39 @@ export const dbUpload = async (req, res) => {
|
|||||||
}
|
}
|
||||||
const options = req.query;
|
const options = req.query;
|
||||||
const file = req.file.path;
|
const file = req.file.path;
|
||||||
await uploadAndParse(file, req, res, options);
|
try {
|
||||||
|
await parseAndApply(file, req, res, options);
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create controller for POST request to '/ontime/new'
|
/**
|
||||||
|
* uploads and parses an excel file
|
||||||
|
* @returns parsed result
|
||||||
|
*/
|
||||||
|
export async function previewExcel(req, res) {
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).send({ message: 'File not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const options = JSON.parse(req.body.options);
|
||||||
|
const file = req.file.path;
|
||||||
|
const data = await parseFile(file, req, res, options);
|
||||||
|
res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meant to create a new project file, it will clear only fields which are specific to a project
|
||||||
|
* @param req
|
||||||
|
* @param res
|
||||||
|
*/
|
||||||
export const postNew: RequestHandler = async (req, res) => {
|
export const postNew: RequestHandler = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const newProjectData: ProjectData = {
|
const newProjectData: ProjectData = {
|
||||||
|
|||||||
@@ -118,3 +118,18 @@ export const validateOscSubscription = [
|
|||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const validatePatchProjectFile = [
|
||||||
|
body('rundown').isArray().optional({ nullable: false }),
|
||||||
|
body('project').isObject().optional({ nullable: false }),
|
||||||
|
body('settings').isObject().optional({ nullable: false }),
|
||||||
|
body('viewSettings').isObject().optional({ nullable: false }),
|
||||||
|
body('aliases').isArray().optional({ nullable: false }),
|
||||||
|
body('userFields').isObject().optional({ nullable: false }),
|
||||||
|
body('osc').isObject().optional({ nullable: false }),
|
||||||
|
(req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
|
|||||||
adapterToUse.data = dbModel;
|
adapterToUse.data = dbModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseJson(adapterToUse.data, true);
|
return parseJson(adapterToUse.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getSettings,
|
getSettings,
|
||||||
getUserFields,
|
getUserFields,
|
||||||
getViewSettings,
|
getViewSettings,
|
||||||
|
patchPartialProjectFile,
|
||||||
poll,
|
poll,
|
||||||
postAliases,
|
postAliases,
|
||||||
postNew,
|
postNew,
|
||||||
@@ -17,12 +18,14 @@ import {
|
|||||||
postSettings,
|
postSettings,
|
||||||
postUserFields,
|
postUserFields,
|
||||||
postViewSettings,
|
postViewSettings,
|
||||||
|
previewExcel,
|
||||||
} from '../controllers/ontimeController.js';
|
} from '../controllers/ontimeController.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateAliases,
|
validateAliases,
|
||||||
validateOSC,
|
validateOSC,
|
||||||
validateOscSubscription,
|
validateOscSubscription,
|
||||||
|
validatePatchProjectFile,
|
||||||
validateSettings,
|
validateSettings,
|
||||||
validateUserFields,
|
validateUserFields,
|
||||||
viewValidator,
|
viewValidator,
|
||||||
@@ -40,6 +43,12 @@ router.get('/db', dbDownload);
|
|||||||
// create route between controller and '/ontime/db' endpoint
|
// create route between controller and '/ontime/db' endpoint
|
||||||
router.post('/db', uploadFile, dbUpload);
|
router.post('/db', uploadFile, dbUpload);
|
||||||
|
|
||||||
|
// create route between controller and '/ontime/excel' endpoint
|
||||||
|
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
|
||||||
|
|
||||||
|
// create route between controller and '/ontime/preview-spreadsheet' endpoint
|
||||||
|
router.post('/preview-spreadsheet', uploadFile, previewExcel);
|
||||||
|
|
||||||
// create route between controller and '/ontime/settings' endpoint
|
// create route between controller and '/ontime/settings' endpoint
|
||||||
router.get('/settings', getSettings);
|
router.get('/settings', getSettings);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import { clock } from '../Clock.js';
|
|||||||
*/
|
*/
|
||||||
export function forceReset() {
|
export function forceReset() {
|
||||||
eventLoader.reset();
|
eventLoader.reset();
|
||||||
sendRefetch();
|
|
||||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,15 +191,11 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
|
|||||||
// modify rundown
|
// modify rundown
|
||||||
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: [id], external: true });
|
||||||
updateTimer([id]);
|
|
||||||
|
|
||||||
// notify event loader that rundown size has changed
|
// notify event loader that rundown size has changed
|
||||||
updateChangeNumEvents();
|
updateChangeNumEvents();
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
|
|
||||||
return newEvent;
|
return newEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,11 +206,7 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
|||||||
|
|
||||||
const newEvent = await cachedEdit(eventData.id, eventData);
|
const newEvent = await cachedEdit(eventData.id, eventData);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: [newEvent.id], external: true });
|
||||||
updateTimer([newEvent.id]);
|
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
|
|
||||||
return newEvent;
|
return newEvent;
|
||||||
}
|
}
|
||||||
@@ -228,14 +219,9 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
|||||||
export async function deleteEvent(eventId) {
|
export async function deleteEvent(eventId) {
|
||||||
await cachedDelete(eventId);
|
await cachedDelete(eventId);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: [eventId], external: true });
|
||||||
updateTimer([eventId]);
|
|
||||||
|
|
||||||
// notify event loader that rundown size has changed
|
// notify event loader that rundown size has changed
|
||||||
updateChangeNumEvents();
|
updateChangeNumEvents();
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -245,9 +231,7 @@ export async function deleteEvent(eventId) {
|
|||||||
export async function deleteAllEvents() {
|
export async function deleteAllEvents() {
|
||||||
await cachedClear();
|
await cachedClear();
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: true, external: true, reset: true });
|
||||||
updateTimer();
|
|
||||||
forceReset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,22 +244,15 @@ export async function deleteAllEvents() {
|
|||||||
export async function reorderEvent(eventId: string, from: number, to: number) {
|
export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||||
const reorderedItem = await cachedReorder(eventId, from, to);
|
const reorderedItem = await cachedReorder(eventId, from, to);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: true, external: true });
|
||||||
updateTimer();
|
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
return reorderedItem;
|
return reorderedItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyDelay(eventId: string) {
|
export async function applyDelay(eventId: string) {
|
||||||
await cachedApplyDelay(eventId);
|
await cachedApplyDelay(eventId);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: true, external: true });
|
||||||
updateTimer();
|
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,11 +264,7 @@ export async function applyDelay(eventId: string) {
|
|||||||
export async function swapEvents(from: string, to: string) {
|
export async function swapEvents(from: string, to: string) {
|
||||||
await cachedSwap(from, to);
|
await cachedSwap(from, to);
|
||||||
|
|
||||||
// notify timer service of changed events
|
notifyChanges({ timer: true, external: true });
|
||||||
updateTimer();
|
|
||||||
|
|
||||||
// advice socket subscribers of change
|
|
||||||
sendRefetch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -301,3 +274,26 @@ export async function swapEvents(from: string, to: string) {
|
|||||||
function updateChangeNumEvents() {
|
function updateChangeNumEvents() {
|
||||||
eventLoader.updateNumEvents();
|
eventLoader.updateNumEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify services of changes in the rundown
|
||||||
|
*/
|
||||||
|
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
|
||||||
|
if (options.timer) {
|
||||||
|
// notify timer service of changed events
|
||||||
|
if (Array.isArray(options.timer)) {
|
||||||
|
updateTimer(options.timer);
|
||||||
|
}
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.reset) {
|
||||||
|
// force rundown to be recalculated
|
||||||
|
forceReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.external) {
|
||||||
|
// advice socket subscribers of change
|
||||||
|
sendRefetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
import { EndAction, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import { parseExcel, parseJson, validateEvent } from '../parser.js';
|
import { parseExcel, parseJson, validateEvent } from '../parser.js';
|
||||||
@@ -525,7 +525,7 @@ describe('test event validator', () => {
|
|||||||
expect(typeof validated.timeStart).toEqual('number');
|
expect(typeof validated.timeStart).toEqual('number');
|
||||||
expect(validated.timeStart).toEqual(0);
|
expect(validated.timeStart).toEqual(0);
|
||||||
expect(typeof validated.timeEnd).toEqual('number');
|
expect(typeof validated.timeEnd).toEqual('number');
|
||||||
expect(validated.timeEnd).toEqual(0);
|
expect(validated.timeEnd).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles bad objects', () => {
|
it('handles bad objects', () => {
|
||||||
@@ -579,24 +579,24 @@ describe('test parseExcel function', () => {
|
|||||||
[
|
[
|
||||||
'Time Start',
|
'Time Start',
|
||||||
'Time End',
|
'Time End',
|
||||||
'Event Title',
|
'Title',
|
||||||
'Presenter Name',
|
'Presenter',
|
||||||
'Event Subtitle',
|
'Subtitle',
|
||||||
'End Action',
|
'End Action',
|
||||||
'Timer type',
|
'Timer type',
|
||||||
'Is Public? (x)',
|
'Public',
|
||||||
'Skip? (x)',
|
'Skip',
|
||||||
'Notes',
|
'Notes',
|
||||||
'User0:test0',
|
'test0',
|
||||||
'User1:test1',
|
'test1',
|
||||||
'User2:test2',
|
'test2',
|
||||||
'User3:test3',
|
'test3',
|
||||||
'User4:test4',
|
'test4',
|
||||||
'User5:test5',
|
'test5',
|
||||||
'User6:test6',
|
'test6',
|
||||||
'user7:test7',
|
'test7',
|
||||||
'user8:test8',
|
'test8',
|
||||||
'user9:test9',
|
'test9',
|
||||||
'Colour',
|
'Colour',
|
||||||
'cue',
|
'cue',
|
||||||
],
|
],
|
||||||
@@ -651,6 +651,19 @@ describe('test parseExcel function', () => {
|
|||||||
[],
|
[],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const partialOptions = {
|
||||||
|
user0: 'test0',
|
||||||
|
user1: 'test1',
|
||||||
|
user2: 'test2',
|
||||||
|
user3: 'test3',
|
||||||
|
user4: 'test4',
|
||||||
|
user5: 'test5',
|
||||||
|
user6: 'test6',
|
||||||
|
user7: 'test7',
|
||||||
|
user8: 'test8',
|
||||||
|
user9: 'test9',
|
||||||
|
};
|
||||||
|
|
||||||
const expectedParsedProjectData = {
|
const expectedParsedProjectData = {
|
||||||
title: 'Test Event',
|
title: 'Test Event',
|
||||||
description: 'test description',
|
description: 'test description',
|
||||||
@@ -706,7 +719,7 @@ describe('test parseExcel function', () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const parsedData = await parseExcel(testdata);
|
const parsedData = parseExcel(testdata, partialOptions);
|
||||||
expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
|
expect(parsedData.project).toStrictEqual(expectedParsedProjectData);
|
||||||
expect(parsedData.rundown).toBeDefined();
|
expect(parsedData.rundown).toBeDefined();
|
||||||
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
||||||
@@ -835,7 +848,16 @@ describe('test views import', () => {
|
|||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
viewSettings: {},
|
viewSettings: {
|
||||||
|
normalColor: '#ffffffcc',
|
||||||
|
warningColor: '#FFAB33',
|
||||||
|
warningThreshold: 120000,
|
||||||
|
dangerColor: '#ED3333',
|
||||||
|
dangerThreshold: 60000,
|
||||||
|
endMessage: '',
|
||||||
|
overrideStyles: false,
|
||||||
|
notAthing: true,
|
||||||
|
},
|
||||||
views: {
|
views: {
|
||||||
overrideStyles: true,
|
overrideStyles: true,
|
||||||
},
|
},
|
||||||
@@ -849,7 +871,7 @@ describe('test views import', () => {
|
|||||||
endMessage: '',
|
endMessage: '',
|
||||||
overrideStyles: false,
|
overrideStyles: false,
|
||||||
};
|
};
|
||||||
const parsed = parseViewSettings(testData, false);
|
const parsed = parseViewSettings(testData);
|
||||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -861,16 +883,7 @@ describe('test views import', () => {
|
|||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const expectedParsedViewSettings = {
|
const parsed = parseViewSettings(testData);
|
||||||
normalColor: '#ffffffcc',
|
expect(parsed).toStrictEqual({});
|
||||||
warningColor: '#FFAB33',
|
|
||||||
warningThreshold: 120000,
|
|
||||||
dangerColor: '#ED3333',
|
|
||||||
dangerThreshold: 60000,
|
|
||||||
endMessage: '',
|
|
||||||
overrideStyles: false,
|
|
||||||
};
|
|
||||||
const parsed = parseViewSettings(testData, true);
|
|
||||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe('mergeObject()', () => {
|
|||||||
third: '',
|
third: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
test.skip('it only merges fields of the first object', () => {
|
test('it only merges fields of the first object', () => {
|
||||||
const a = {
|
const a = {
|
||||||
first: 'yes',
|
first: 'yes',
|
||||||
second: 'yes',
|
second: 'yes',
|
||||||
@@ -64,6 +64,35 @@ describe('mergeObject()', () => {
|
|||||||
third: '',
|
third: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
test('merges nested objects', () => {
|
||||||
|
// Define a sample object with nested properties
|
||||||
|
const a = {
|
||||||
|
name: 'John',
|
||||||
|
address: {
|
||||||
|
city: 'New York',
|
||||||
|
postalCode: '10001',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define a partial object with nested properties for merging
|
||||||
|
const b = {
|
||||||
|
name: 'Doe',
|
||||||
|
address: {
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const merged = mergeObject(a, b);
|
||||||
|
|
||||||
|
expect(merged.name).toBe('Doe');
|
||||||
|
expect(merged.address.city).toBe('San Francisco');
|
||||||
|
// @ts-expect-error -- its ok, just checking
|
||||||
|
expect(merged.address.state).toBe('CA');
|
||||||
|
expect(merged.address.postalCode).toBe('10001');
|
||||||
|
expect(merged.address).not.toBe(a.address);
|
||||||
|
expect(merged.address).not.toBe(b.address);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('removeUndefined()', () => {
|
describe('removeUndefined()', () => {
|
||||||
|
|||||||
+178
-179
@@ -1,18 +1,27 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
import {
|
||||||
// @ts-nocheck -- not ready to fully type
|
generateId,
|
||||||
|
isExcelImportMap,
|
||||||
import fs from 'fs';
|
type ExcelImportMap,
|
||||||
import xlsx from 'node-xlsx';
|
defaultExcelImportMap,
|
||||||
import { generateId, calculateDuration } from 'ontime-utils';
|
validateEndAction,
|
||||||
|
validateTimerType,
|
||||||
|
type ExcelImportOptions,
|
||||||
|
validateTimes,
|
||||||
|
} from 'ontime-utils';
|
||||||
import {
|
import {
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
EndAction,
|
|
||||||
OntimeEvent,
|
OntimeEvent,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
SupportedEvent,
|
SupportedEvent,
|
||||||
TimerType,
|
ProjectData,
|
||||||
UserFields,
|
UserFields,
|
||||||
|
EndAction,
|
||||||
|
TimerType,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import xlsx from 'node-xlsx';
|
||||||
|
|
||||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||||
import { dbModel } from '../models/dataModel.js';
|
import { dbModel } from '../models/dataModel.js';
|
||||||
import { deleteFile, makeString } from './parserUtils.js';
|
import { deleteFile, makeString } from './parserUtils.js';
|
||||||
@@ -33,27 +42,55 @@ export const JSON_MIME = 'application/json';
|
|||||||
/**
|
/**
|
||||||
* @description Excel array parser
|
* @description Excel array parser
|
||||||
* @param {array} excelData - array with excel sheet
|
* @param {array} excelData - array with excel sheet
|
||||||
|
* @param {ExcelImportOptions} options - an object that contains the import map
|
||||||
* @returns {object} - parsed object
|
* @returns {object} - parsed object
|
||||||
*/
|
*/
|
||||||
export const parseExcel = async (excelData) => {
|
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
|
||||||
|
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
||||||
const projectData: Partial<ProjectData> = {
|
const projectData: Partial<ProjectData> = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
publicUrl: '',
|
publicUrl: '',
|
||||||
|
publicInfo: '',
|
||||||
backstageUrl: '',
|
backstageUrl: '',
|
||||||
|
backstageInfo: '',
|
||||||
|
};
|
||||||
|
const customUserFields: Partial<UserFields> = {
|
||||||
|
user0: importMap.user0,
|
||||||
|
user1: importMap.user1,
|
||||||
|
user2: importMap.user2,
|
||||||
|
user3: importMap.user3,
|
||||||
|
user4: importMap.user4,
|
||||||
|
user5: importMap.user5,
|
||||||
|
user6: importMap.user6,
|
||||||
|
user7: importMap.user7,
|
||||||
|
user8: importMap.user8,
|
||||||
|
user9: importMap.user9,
|
||||||
};
|
};
|
||||||
const customUserFields: Partial<UserFields> = {};
|
|
||||||
const rundown: OntimeRundown = [];
|
const rundown: OntimeRundown = [];
|
||||||
let timeStartIndex: number | null = null;
|
|
||||||
let timeEndIndex: number | null = null;
|
// title stuff: strings
|
||||||
let titleIndex: number | null = null;
|
let titleIndex: number | null = null;
|
||||||
let cueIndex: number | null = null;
|
let cueIndex: number | null = null;
|
||||||
let presenterIndex: number | null = null;
|
let presenterIndex: number | null = null;
|
||||||
let subtitleIndex: number | null = null;
|
let subtitleIndex: number | null = null;
|
||||||
let isPublicIndex: number | null = null;
|
|
||||||
let skipIndex: number | null = null;
|
|
||||||
let notesIndex: number | null = null;
|
let notesIndex: number | null = null;
|
||||||
let colourIndex: number | null = null;
|
let colourIndex: number | null = null;
|
||||||
|
|
||||||
|
// options: booleans
|
||||||
|
let isPublicIndex: number | null = null;
|
||||||
|
let skipIndex: number | null = null;
|
||||||
|
|
||||||
|
// times: numbers
|
||||||
|
let timeStartIndex: number | null = null;
|
||||||
|
let timeEndIndex: number | null = null;
|
||||||
|
let durationIndex: number | null = null;
|
||||||
|
|
||||||
|
// options: enum properties
|
||||||
|
let endActionIndex: number | null = null;
|
||||||
|
let timerTypeIndex: number | null = null;
|
||||||
|
|
||||||
|
// user fields: strings
|
||||||
let user0Index: number | null = null;
|
let user0Index: number | null = null;
|
||||||
let user1Index: number | null = null;
|
let user1Index: number | null = null;
|
||||||
let user2Index: number | null = null;
|
let user2Index: number | null = null;
|
||||||
@@ -64,13 +101,11 @@ export const parseExcel = async (excelData) => {
|
|||||||
let user7Index: number | null = null;
|
let user7Index: number | null = null;
|
||||||
let user8Index: number | null = null;
|
let user8Index: number | null = null;
|
||||||
let user9Index: number | null = null;
|
let user9Index: number | null = null;
|
||||||
let endActionIndex: number | null = null;
|
|
||||||
let timerTypeIndex: number | null = null;
|
|
||||||
|
|
||||||
excelData
|
excelData
|
||||||
.filter((e) => e.length > 0)
|
.filter((e) => e.length > 0)
|
||||||
.forEach((row) => {
|
.forEach((row) => {
|
||||||
// project data imports are on the column to the right
|
// these fields contain the data to its right
|
||||||
let projectTitleNext = false;
|
let projectTitleNext = false;
|
||||||
let projectDescriptionNext = false;
|
let projectDescriptionNext = false;
|
||||||
let publicUrlNext = false;
|
let publicUrlNext = false;
|
||||||
@@ -81,29 +116,31 @@ export const parseExcel = async (excelData) => {
|
|||||||
const event: Partial<OntimeEvent> = {};
|
const event: Partial<OntimeEvent> = {};
|
||||||
|
|
||||||
row.forEach((column, j) => {
|
row.forEach((column, j) => {
|
||||||
// check flags
|
// 1. we check if we have set a flag for a known field
|
||||||
if (projectTitleNext) {
|
if (projectTitleNext) {
|
||||||
projectData.title = column;
|
projectData.title = makeString(column, '');
|
||||||
projectTitleNext = false;
|
projectTitleNext = false;
|
||||||
} else if (projectDescriptionNext) {
|
} else if (projectDescriptionNext) {
|
||||||
projectData.description = column;
|
projectData.description = makeString(column, '');
|
||||||
projectDescriptionNext = false;
|
projectDescriptionNext = false;
|
||||||
} else if (publicUrlNext) {
|
} else if (publicUrlNext) {
|
||||||
projectData.publicUrl = column;
|
projectData.publicUrl = makeString(column, '');
|
||||||
publicUrlNext = false;
|
publicUrlNext = false;
|
||||||
} else if (publicInfoNext) {
|
} else if (publicInfoNext) {
|
||||||
projectData.publicInfo = column;
|
projectData.publicInfo = makeString(column, '');
|
||||||
publicInfoNext = false;
|
publicInfoNext = false;
|
||||||
} else if (backstageUrlNext) {
|
} else if (backstageUrlNext) {
|
||||||
projectData.backstageUrl = column;
|
projectData.backstageUrl = makeString(column, '');
|
||||||
backstageUrlNext = false;
|
backstageUrlNext = false;
|
||||||
} else if (backstageInfoNext) {
|
} else if (backstageInfoNext) {
|
||||||
projectData.backstageInfo = column;
|
projectData.backstageInfo = makeString(column, '');
|
||||||
backstageInfoNext = false;
|
backstageInfoNext = false;
|
||||||
} else if (j === timeStartIndex) {
|
} else if (j === timeStartIndex) {
|
||||||
event.timeStart = parseExcelDate(column);
|
event.timeStart = parseExcelDate(column);
|
||||||
} else if (j === timeEndIndex) {
|
} else if (j === timeEndIndex) {
|
||||||
event.timeEnd = parseExcelDate(column);
|
event.timeEnd = parseExcelDate(column);
|
||||||
|
} else if (j === durationIndex) {
|
||||||
|
event.duration = parseExcelDate(column);
|
||||||
} else if (j === titleIndex) {
|
} else if (j === titleIndex) {
|
||||||
event.title = makeString(column, '');
|
event.title = makeString(column, '');
|
||||||
} else if (j === cueIndex) {
|
} else if (j === cueIndex) {
|
||||||
@@ -119,155 +156,130 @@ export const parseExcel = async (excelData) => {
|
|||||||
} else if (j === notesIndex) {
|
} else if (j === notesIndex) {
|
||||||
event.note = makeString(column, '');
|
event.note = makeString(column, '');
|
||||||
} else if (j === endActionIndex) {
|
} else if (j === endActionIndex) {
|
||||||
if (column === '') {
|
event.endAction = validateEndAction(column);
|
||||||
event.endAction = EndAction.None;
|
|
||||||
} else {
|
|
||||||
event.endAction = column;
|
|
||||||
}
|
|
||||||
} else if (j === timerTypeIndex) {
|
} else if (j === timerTypeIndex) {
|
||||||
if (column === '') {
|
event.timerType = validateTimerType(column);
|
||||||
event.timerType = TimerType.CountDown;
|
|
||||||
} else {
|
|
||||||
event.timerType = column;
|
|
||||||
}
|
|
||||||
} else if (j === colourIndex) {
|
} else if (j === colourIndex) {
|
||||||
event.colour = column;
|
event.colour = makeString(column, '');
|
||||||
} else if (j === user0Index) {
|
} else if (j === user0Index) {
|
||||||
event.user0 = column;
|
event.user0 = makeString(column, '');
|
||||||
} else if (j === user1Index) {
|
} else if (j === user1Index) {
|
||||||
event.user1 = column;
|
event.user1 = makeString(column, '');
|
||||||
} else if (j === user2Index) {
|
} else if (j === user2Index) {
|
||||||
event.user2 = column;
|
event.user2 = makeString(column, '');
|
||||||
} else if (j === user3Index) {
|
} else if (j === user3Index) {
|
||||||
event.user3 = column;
|
event.user3 = makeString(column, '');
|
||||||
} else if (j === user4Index) {
|
} else if (j === user4Index) {
|
||||||
event.user4 = column;
|
event.user4 = makeString(column, '');
|
||||||
} else if (j === user5Index) {
|
} else if (j === user5Index) {
|
||||||
event.user5 = column;
|
event.user5 = makeString(column, '');
|
||||||
} else if (j === user6Index) {
|
} else if (j === user6Index) {
|
||||||
event.user6 = column;
|
event.user6 = makeString(column, '');
|
||||||
} else if (j === user7Index) {
|
} else if (j === user7Index) {
|
||||||
event.user7 = column;
|
event.user7 = makeString(column, '');
|
||||||
} else if (j === user8Index) {
|
} else if (j === user8Index) {
|
||||||
event.user8 = column;
|
event.user8 = makeString(column, '');
|
||||||
} else if (j === user9Index) {
|
} else if (j === user9Index) {
|
||||||
event.user9 = column;
|
event.user9 = makeString(column, '');
|
||||||
} else {
|
} else {
|
||||||
|
// 2. if there is no flag, lets see if we know the field type
|
||||||
if (typeof column === 'string') {
|
if (typeof column === 'string') {
|
||||||
const col = column.toLowerCase();
|
const col = column.toLowerCase();
|
||||||
|
|
||||||
// look for keywords
|
// look for keywords
|
||||||
// need to make sure it is a string first
|
// need to make sure it is a string first
|
||||||
switch (col) {
|
switch (col) {
|
||||||
case 'project name':
|
case importMap.projectName:
|
||||||
projectTitleNext = true;
|
projectTitleNext = true;
|
||||||
break;
|
break;
|
||||||
case 'project description':
|
case importMap.projectDescription:
|
||||||
projectDescriptionNext = true;
|
projectDescriptionNext = true;
|
||||||
break;
|
break;
|
||||||
case 'public url':
|
case importMap.publicUrl:
|
||||||
publicUrlNext = true;
|
publicUrlNext = true;
|
||||||
break;
|
break;
|
||||||
case 'public info':
|
case importMap.publicInfo:
|
||||||
publicInfoNext = true;
|
publicInfoNext = true;
|
||||||
break;
|
break;
|
||||||
case 'backstage url':
|
case importMap.backstageUrl:
|
||||||
backstageUrlNext = true;
|
backstageUrlNext = true;
|
||||||
break;
|
break;
|
||||||
case 'backstage info':
|
case importMap.backstageInfo:
|
||||||
backstageInfoNext = true;
|
backstageInfoNext = true;
|
||||||
break;
|
break;
|
||||||
case 'time start':
|
case importMap.timeStart:
|
||||||
case 'start':
|
|
||||||
timeStartIndex = j;
|
timeStartIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'time end':
|
case importMap.timeEnd:
|
||||||
case 'end':
|
|
||||||
case 'finish':
|
|
||||||
timeEndIndex = j;
|
timeEndIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'cue':
|
case importMap.duration:
|
||||||
case 'page':
|
durationIndex = j;
|
||||||
|
break;
|
||||||
|
case importMap.cue:
|
||||||
cueIndex = j;
|
cueIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'event title':
|
case importMap.title:
|
||||||
case 'title':
|
|
||||||
titleIndex = j;
|
titleIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'presenter name':
|
case importMap.presenter:
|
||||||
case 'speaker':
|
|
||||||
case 'presenter':
|
|
||||||
presenterIndex = j;
|
presenterIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'event subtitle':
|
case importMap.subtitle:
|
||||||
case 'subtitle':
|
|
||||||
subtitleIndex = j;
|
subtitleIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'is public? (x)':
|
case importMap.isPublic:
|
||||||
case 'is public':
|
|
||||||
case 'public':
|
|
||||||
isPublicIndex = j;
|
isPublicIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'skip? (x)':
|
case importMap.skip:
|
||||||
case 'skip?':
|
|
||||||
case 'skip':
|
|
||||||
skipIndex = j;
|
skipIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'note':
|
case importMap.note:
|
||||||
case 'notes':
|
|
||||||
notesIndex = j;
|
notesIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'colour':
|
case importMap.colour:
|
||||||
case 'color':
|
|
||||||
colourIndex = j;
|
colourIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'end action':
|
case importMap.endAction:
|
||||||
endActionIndex = j;
|
endActionIndex = j;
|
||||||
break;
|
break;
|
||||||
case 'timer type':
|
case importMap.timerType:
|
||||||
timerTypeIndex = j;
|
timerTypeIndex = j;
|
||||||
break;
|
break;
|
||||||
default:
|
case importMap.user0:
|
||||||
// look for user defined
|
user0Index = j;
|
||||||
if (col.startsWith('user')) {
|
|
||||||
const index = column.charAt(4);
|
|
||||||
// name is the bit after the :
|
|
||||||
const [, name] = column.split(':');
|
|
||||||
if (typeof name !== 'undefined') {
|
|
||||||
if (index === '0') {
|
|
||||||
customUserFields.user0 = name;
|
|
||||||
user0Index = j;
|
|
||||||
} else if (index === '1') {
|
|
||||||
customUserFields.user1 = name;
|
|
||||||
user1Index = j;
|
|
||||||
} else if (index === '2') {
|
|
||||||
customUserFields.user2 = name;
|
|
||||||
user2Index = j;
|
|
||||||
} else if (index === '3') {
|
|
||||||
customUserFields.user3 = name;
|
|
||||||
user3Index = j;
|
|
||||||
} else if (index === '4') {
|
|
||||||
customUserFields.user4 = name;
|
|
||||||
user4Index = j;
|
|
||||||
} else if (index === '5') {
|
|
||||||
customUserFields.user5 = name;
|
|
||||||
user5Index = j;
|
|
||||||
} else if (index === '6') {
|
|
||||||
customUserFields.user6 = name;
|
|
||||||
user6Index = j;
|
|
||||||
} else if (index === '7') {
|
|
||||||
customUserFields.user7 = name;
|
|
||||||
user7Index = j;
|
|
||||||
} else if (index === '8') {
|
|
||||||
customUserFields.user8 = name;
|
|
||||||
user8Index = j;
|
|
||||||
} else if (index === '9') {
|
|
||||||
customUserFields.user9 = name;
|
|
||||||
user9Index = j;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
case importMap.user1:
|
||||||
|
user1Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user2:
|
||||||
|
user2Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user3:
|
||||||
|
user3Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user4:
|
||||||
|
user4Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user5:
|
||||||
|
user5Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user6:
|
||||||
|
user6Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user7:
|
||||||
|
user7Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user8:
|
||||||
|
user8Index = j;
|
||||||
|
break;
|
||||||
|
case importMap.user9:
|
||||||
|
user9Index = j;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// we don't know how to handle this column
|
||||||
|
// just ignore it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,10 +287,10 @@ export const parseExcel = async (excelData) => {
|
|||||||
|
|
||||||
if (Object.keys(event).length > 0) {
|
if (Object.keys(event).length > 0) {
|
||||||
// if any data was found, push to array
|
// if any data was found, push to array
|
||||||
// take care of it in the next step
|
|
||||||
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rundown,
|
rundown,
|
||||||
project: projectData,
|
project: projectData,
|
||||||
@@ -286,17 +298,16 @@ export const parseExcel = async (excelData) => {
|
|||||||
app: 'ontime',
|
app: 'ontime',
|
||||||
version: 2,
|
version: 2,
|
||||||
},
|
},
|
||||||
userFields: { ...dbModel.userFields, ...customUserFields },
|
userFields: customUserFields,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description JSON parser function for v1 of data system
|
* @description JSON parser function for ontime project file
|
||||||
* @param {object} jsonData - json data JSON object to be parsed
|
* @param {object} jsonData - project file to be parsed
|
||||||
* @param {boolean} [enforce=false] - flag, tells to create an object anyway
|
|
||||||
* @returns {object} - parsed object
|
* @returns {object} - parsed object
|
||||||
*/
|
*/
|
||||||
export const parseJson = async (jsonData, enforce = false): Promise<DatabaseModel | null> => {
|
export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
|
||||||
if (!jsonData || typeof jsonData !== 'object') {
|
if (!jsonData || typeof jsonData !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -307,17 +318,17 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
|
|||||||
// parse Events
|
// parse Events
|
||||||
returnData.rundown = parseRundown(jsonData);
|
returnData.rundown = parseRundown(jsonData);
|
||||||
// parse Event
|
// parse Event
|
||||||
returnData.project = parseProject(jsonData, enforce);
|
returnData.project = parseProject(jsonData) ?? dbModel.project;
|
||||||
// Settings handled partially
|
// Settings handled partially
|
||||||
returnData.settings = parseSettings(jsonData, enforce);
|
returnData.settings = parseSettings(jsonData) ?? dbModel.settings;
|
||||||
// View settings handled partially
|
// View settings handled partially
|
||||||
returnData.viewSettings = parseViewSettings(jsonData, enforce);
|
returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings;
|
||||||
// Import Aliases if any
|
// Import Aliases if any
|
||||||
returnData.aliases = parseAliases(jsonData);
|
returnData.aliases = parseAliases(jsonData);
|
||||||
// Import user fields if any
|
// Import user fields if any
|
||||||
returnData.userFields = parseUserFields(jsonData);
|
returnData.userFields = parseUserFields(jsonData);
|
||||||
// Import OSC settings if any
|
// Import OSC settings if any
|
||||||
returnData.osc = parseOsc(jsonData, enforce);
|
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
||||||
// Import HTTP settings if any
|
// Import HTTP settings if any
|
||||||
// returnData.http = parseHttp(jsonData, enforce);
|
// returnData.http = parseHttp(jsonData, enforce);
|
||||||
|
|
||||||
@@ -344,19 +355,19 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
|||||||
|
|
||||||
const e = eventArgs;
|
const e = eventArgs;
|
||||||
const d = eventDef;
|
const d = eventDef;
|
||||||
const start = e.timeStart != null && typeof e.timeStart === 'number' ? e.timeStart : d.timeStart;
|
|
||||||
const end = e.timeEnd != null && typeof e.timeEnd === 'number' ? e.timeEnd : d.timeEnd;
|
const { timeStart, timeEnd, duration } = validateTimes(e.timeStart, e.timeEnd, e.duration);
|
||||||
|
|
||||||
event = {
|
event = {
|
||||||
...d,
|
...d,
|
||||||
title: makeString(e.title, d.title),
|
title: makeString(e.title, d.title),
|
||||||
subtitle: makeString(e.subtitle, d.subtitle),
|
subtitle: makeString(e.subtitle, d.subtitle),
|
||||||
presenter: makeString(e.presenter, d.presenter),
|
presenter: makeString(e.presenter, d.presenter),
|
||||||
timeStart: start,
|
timeStart,
|
||||||
timeEnd: end,
|
timeEnd,
|
||||||
endAction: makeString(e.endAction, d.endAction),
|
duration,
|
||||||
timerType: makeString(e.timerType, d.timerType),
|
endAction: validateEndAction(e.endAction, EndAction.None),
|
||||||
duration: calculateDuration(start, end),
|
timerType: validateTimerType(e.timerType, TimerType.CountDown),
|
||||||
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
|
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
|
||||||
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
|
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
|
||||||
note: makeString(e.note, d.note),
|
note: makeString(e.note, d.note),
|
||||||
@@ -371,8 +382,8 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
|||||||
user8: makeString(e.user8, d.user8),
|
user8: makeString(e.user8, d.user8),
|
||||||
user9: makeString(e.user9, d.user9),
|
user9: makeString(e.user9, d.user9),
|
||||||
colour: makeString(e.colour, d.colour),
|
colour: makeString(e.colour, d.colour),
|
||||||
id,
|
|
||||||
cue: makeString(e.cue, cueFallback),
|
cue: makeString(e.cue, cueFallback),
|
||||||
|
id,
|
||||||
type: 'event',
|
type: 'event',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -380,68 +391,56 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
|||||||
return event;
|
return event;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' };
|
type ResponseOK = {
|
||||||
type ResponseError = { error: true; message: string };
|
data: Partial<DatabaseModel>;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Middleware function that checks file type and calls relevant parser
|
* @description Middleware function that checks file type and calls relevant parser
|
||||||
* @param {string} file - reference to file
|
* @param {string} file - reference to file
|
||||||
|
* @param options - import options
|
||||||
* @return {object} - parse result message
|
* @return {object} - parse result message
|
||||||
*/
|
*/
|
||||||
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
|
export const fileHandler = async (file: string, options: ExcelImportOptions): Promise<Partial<ResponseOK>> => {
|
||||||
let res: Partial<ResponseOK | ResponseError> = {};
|
const res: Partial<ResponseOK> = {};
|
||||||
|
|
||||||
// check which file type are we dealing with
|
// check which file type are we dealing with
|
||||||
if (file.endsWith('.xlsx')) {
|
if (file.endsWith('.xlsx')) {
|
||||||
try {
|
// we need to check that the options are applicable
|
||||||
const excelData = xlsx
|
if (!isExcelImportMap(options)) {
|
||||||
.parse(file, { cellDates: true })
|
throw new Error('Got incorrect options to excel import', JSON.parse(options));
|
||||||
.find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule');
|
}
|
||||||
|
|
||||||
// we only look at worksheets called ontime or event schedule
|
const excelData = xlsx
|
||||||
if (excelData?.data) {
|
.parse(file, { cellDates: true })
|
||||||
const dataFromExcel = await parseExcel(excelData.data);
|
.find(({ name }) => name.toLowerCase() === options.worksheet);
|
||||||
res.data = {};
|
|
||||||
res.data.rundown = parseRundown(dataFromExcel);
|
if (excelData?.data) {
|
||||||
res.data.project = parseProject(dataFromExcel, true);
|
const dataFromExcel = parseExcel(excelData.data, options);
|
||||||
res.data.userFields = parseUserFields(dataFromExcel);
|
// we run the parsed data through an extra step to ensure the objects shape
|
||||||
res.message = 'success';
|
res.data = {};
|
||||||
} else {
|
res.data.rundown = parseRundown(dataFromExcel);
|
||||||
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
|
res.data.project = parseProject(dataFromExcel);
|
||||||
res = {
|
res.data.userFields = parseUserFields(dataFromExcel);
|
||||||
error: true,
|
return res;
|
||||||
message: errorMessage,
|
} else {
|
||||||
};
|
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
res = { error: true, message: `Error parsing file: ${error}` };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.endsWith('.json')) {
|
if (file.endsWith('.json')) {
|
||||||
// if json check version
|
// if json check version
|
||||||
const rawdata = fs.readFileSync(file);
|
const rawdata = fs.readFileSync(file).toString();
|
||||||
let uploadedJson = null;
|
let uploadedJson = null;
|
||||||
|
|
||||||
try {
|
uploadedJson = JSON.parse(rawdata);
|
||||||
uploadedJson = JSON.parse(rawdata);
|
if (uploadedJson.settings.version !== 2) {
|
||||||
} catch (error) {
|
throw new Error(`Project version unknown ${uploadedJson.settings.version}`);
|
||||||
return { error: true, message: 'Error parsing JSON file' };
|
|
||||||
}
|
}
|
||||||
|
res.data = await parseJson(uploadedJson);
|
||||||
|
|
||||||
if (uploadedJson.settings.version === 2) {
|
// delete file
|
||||||
try {
|
await deleteFile(file);
|
||||||
res.data = await parseJson(uploadedJson);
|
return res;
|
||||||
res.message = 'success';
|
|
||||||
} catch (error) {
|
|
||||||
res = { error: true, message: `Error parsing file: ${error}` };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res = { error: true, message: 'Error parsing file, version unknown' };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete file
|
|
||||||
await deleteFile(file);
|
|
||||||
return res;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { generateId } from 'ontime-utils';
|
import { generateId } from 'ontime-utils';
|
||||||
import {
|
import {
|
||||||
Alias,
|
Alias,
|
||||||
EndAction,
|
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
OSCSettings,
|
OSCSettings,
|
||||||
OscSubscription,
|
OscSubscription,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
ProjectData,
|
ProjectData,
|
||||||
Settings,
|
Settings,
|
||||||
TimerLifeCycle,
|
TimerLifeCycle,
|
||||||
TimerType,
|
|
||||||
UserFields,
|
UserFields,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -45,18 +43,6 @@ export const parseRundown = (data): OntimeRundown => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate the right endAction is used
|
|
||||||
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
|
|
||||||
e.endAction = EndAction.None;
|
|
||||||
console.log('WARNING: invalid End Action provided, using default');
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate the right timerType is used
|
|
||||||
if (e.timerType && !Object.values(TimerType).includes(e.timerType)) {
|
|
||||||
e.timerType = TimerType.CountDown;
|
|
||||||
console.log('WARNING: invalid Timer Type provided, using default');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.type === 'event') {
|
if (e.type === 'event') {
|
||||||
eventIndex += 1;
|
eventIndex += 1;
|
||||||
const event = validateEvent(e, eventIndex.toString());
|
const event = validateEvent(e, eventIndex.toString());
|
||||||
@@ -88,10 +74,9 @@ export const parseRundown = (data): OntimeRundown => {
|
|||||||
/**
|
/**
|
||||||
* Parse event portion of an entry
|
* Parse event portion of an entry
|
||||||
* @param {object} data - data object
|
* @param {object} data - data object
|
||||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
|
||||||
* @returns {object} - event object data
|
* @returns {object} - event object data
|
||||||
*/
|
*/
|
||||||
export const parseProject = (data, enforce): ProjectData => {
|
export const parseProject = (data): ProjectData => {
|
||||||
let newProjectData: Partial<ProjectData> = {};
|
let newProjectData: Partial<ProjectData> = {};
|
||||||
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
|
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
|
||||||
// TODO: Remove eventually
|
// TODO: Remove eventually
|
||||||
@@ -109,9 +94,6 @@ export const parseProject = (data, enforce): ProjectData => {
|
|||||||
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
|
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
|
||||||
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
|
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
|
||||||
};
|
};
|
||||||
} else if (enforce) {
|
|
||||||
newProjectData = { ...dbModel.project };
|
|
||||||
console.log('Created project object in db');
|
|
||||||
}
|
}
|
||||||
return newProjectData as ProjectData;
|
return newProjectData as ProjectData;
|
||||||
};
|
};
|
||||||
@@ -119,10 +101,9 @@ export const parseProject = (data, enforce): ProjectData => {
|
|||||||
/**
|
/**
|
||||||
* Parse settings portion of an entry
|
* Parse settings portion of an entry
|
||||||
* @param {object} data - data object
|
* @param {object} data - data object
|
||||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
|
||||||
* @returns {object} - event object data
|
* @returns {object} - event object data
|
||||||
*/
|
*/
|
||||||
export const parseSettings = (data, enforce): Settings => {
|
export const parseSettings = (data): Settings => {
|
||||||
let newSettings: Partial<Settings> = {};
|
let newSettings: Partial<Settings> = {};
|
||||||
if ('settings' in data) {
|
if ('settings' in data) {
|
||||||
console.log('Found settings definition, importing...');
|
console.log('Found settings definition, importing...');
|
||||||
@@ -146,9 +127,6 @@ export const parseSettings = (data, enforce): Settings => {
|
|||||||
...settings,
|
...settings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (enforce) {
|
|
||||||
newSettings = dbModel.settings;
|
|
||||||
console.log('Created settings object in db');
|
|
||||||
}
|
}
|
||||||
return newSettings as Settings;
|
return newSettings as Settings;
|
||||||
};
|
};
|
||||||
@@ -156,10 +134,9 @@ export const parseSettings = (data, enforce): Settings => {
|
|||||||
/**
|
/**
|
||||||
* Parse settings portion of an entry
|
* Parse settings portion of an entry
|
||||||
* @param {object} data - data object
|
* @param {object} data - data object
|
||||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
|
||||||
* @returns {object} - event object data
|
* @returns {object} - event object data
|
||||||
*/
|
*/
|
||||||
export const parseViewSettings = (data, enforce): ViewSettings => {
|
export const parseViewSettings = (data): ViewSettings => {
|
||||||
let newViews: Partial<ViewSettings> = {};
|
let newViews: Partial<ViewSettings> = {};
|
||||||
if ('viewSettings' in data) {
|
if ('viewSettings' in data) {
|
||||||
console.log('Found view definition, importing...');
|
console.log('Found view definition, importing...');
|
||||||
@@ -175,13 +152,7 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
|
|||||||
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
|
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
// write to db
|
newViews = { ...viewSettings };
|
||||||
newViews = {
|
|
||||||
...viewSettings,
|
|
||||||
};
|
|
||||||
} else if (enforce) {
|
|
||||||
newViews = dbModel.viewSettings;
|
|
||||||
console.log('Created viewSettings object in db');
|
|
||||||
}
|
}
|
||||||
return newViews as ViewSettings;
|
return newViews as ViewSettings;
|
||||||
};
|
};
|
||||||
@@ -224,16 +195,11 @@ export const validateOscObject = (data: OscSubscription): boolean => {
|
|||||||
/**
|
/**
|
||||||
* Parse osc portion of an entry
|
* Parse osc portion of an entry
|
||||||
*/
|
*/
|
||||||
export const parseOsc = (
|
export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
|
||||||
data: {
|
|
||||||
osc?: Partial<OSCSettings>;
|
|
||||||
},
|
|
||||||
enforce: boolean,
|
|
||||||
): OSCSettings | Record<string, never> => {
|
|
||||||
if ('osc' in data) {
|
if ('osc' in data) {
|
||||||
console.log('Found OSC definition, importing...');
|
console.log('Found OSC definition, importing...');
|
||||||
|
|
||||||
const loadedConfig = data?.osc || {};
|
const loadedConfig = data.osc || {};
|
||||||
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
|
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
|
||||||
? loadedConfig.subscriptions
|
? loadedConfig.subscriptions
|
||||||
: dbModel.osc.subscriptions;
|
: dbModel.osc.subscriptions;
|
||||||
@@ -246,10 +212,7 @@ export const parseOsc = (
|
|||||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||||
subscriptions: validatedSubscriptions,
|
subscriptions: validatedSubscriptions,
|
||||||
};
|
};
|
||||||
} else if (enforce) {
|
}
|
||||||
console.log('Created OSC object in db');
|
|
||||||
return { ...dbModel.osc };
|
|
||||||
} else return {};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { deepmerge } from 'ontime-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Ensures variable is string, it skips object types
|
* @description Ensures variable is string, it skips object types
|
||||||
@@ -52,16 +53,30 @@ export const isEmptyObject = (obj: object) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Merges two objects, suppressing undefined keys
|
* @description Merges two objects, suppressing undefined keys
|
||||||
* @param {object} a
|
* @param {object} a - any object
|
||||||
* @param {object} b
|
* @param {object} b - a potential partial object of same time as a
|
||||||
*/
|
*/
|
||||||
export const mergeObject = (a, b) => {
|
export function mergeObject<T extends Record<string, any>>(a: T, b: Partial<Record<keyof T, any>>): T {
|
||||||
const merged = {};
|
const merged = { ...a };
|
||||||
Object.keys({ ...a, ...b }).map((key) => {
|
|
||||||
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key];
|
for (const key in b) {
|
||||||
});
|
const aValue = a[key];
|
||||||
|
const bValue = b[key];
|
||||||
|
|
||||||
|
// ignore keys that do not exist in original object
|
||||||
|
if (!Object.hasOwn(merged, key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) {
|
||||||
|
// @ts-expect-error -- library side, ignore for now
|
||||||
|
merged[key] = deepmerge(aValue, bValue);
|
||||||
|
} else if (bValue !== undefined) {
|
||||||
|
merged[key] = bValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
return merged;
|
return merged;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Removes undefined
|
* @description Removes undefined
|
||||||
|
|||||||
@@ -89,14 +89,18 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
|
|||||||
* @returns {number} - time in milliseconds
|
* @returns {number} - time in milliseconds
|
||||||
|
|
||||||
*/
|
*/
|
||||||
export const parseExcelDate = (excelDate: string): number => {
|
export const parseExcelDate = (excelDate: unknown): number => {
|
||||||
// attempt converting to date object
|
if (excelDate instanceof Date) {
|
||||||
const date = new Date(excelDate);
|
return dateToMillis(excelDate);
|
||||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
} else if (typeof excelDate === 'string') {
|
||||||
return dateToMillis(date);
|
const date = new Date(excelDate);
|
||||||
} else if (isTimeString(excelDate)) {
|
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||||
return forgivingStringToMillis(excelDate);
|
return dateToMillis(date);
|
||||||
|
} else if (isTimeString(excelDate)) {
|
||||||
|
return forgivingStringToMillis(excelDate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -1,12 +1,13 @@
|
|||||||
// runtime utils
|
// runtime utils
|
||||||
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
|
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
|
||||||
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
||||||
|
export { validateTimes } from './src/validate-events/validateEvent.js';
|
||||||
|
export { calculateDuration } from './src/validate-events/validateEvent.js';
|
||||||
|
|
||||||
// rundown utils
|
// rundown utils
|
||||||
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
||||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||||
export { generateId } from './src/generate-id/generateId.js';
|
export { generateId } from './src/generate-id/generateId.js';
|
||||||
export { calculateDuration } from './src/rundown-utils/rundownUtils.js';
|
|
||||||
export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js';
|
export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js';
|
||||||
|
|
||||||
// format utils
|
// format utils
|
||||||
@@ -18,5 +19,21 @@ export { millisToString } from './src/date-utils/millisToString.js';
|
|||||||
// time utils
|
// time utils
|
||||||
export { dayInMs, mts } from './src/timeConstants.js';
|
export { dayInMs, mts } from './src/timeConstants.js';
|
||||||
|
|
||||||
|
// helpers from externals
|
||||||
|
export { deepmerge } from './src/externals/deepmerge.js';
|
||||||
|
|
||||||
// generic utilities
|
// generic utilities
|
||||||
export { isNumeric } from './src/types/types.js';
|
export { isNumeric } from './src/types/types.js';
|
||||||
|
|
||||||
|
// model validation
|
||||||
|
export { validateEndAction, validateTimerType } from './src/validate-events/validateEvent.js';
|
||||||
|
|
||||||
|
// feature business logic
|
||||||
|
|
||||||
|
// feature business logic - excel import
|
||||||
|
export {
|
||||||
|
type ExcelImportMap,
|
||||||
|
type ExcelImportOptions,
|
||||||
|
defaultExcelImportMap,
|
||||||
|
isExcelImportMap,
|
||||||
|
} from './src/feature/excel-import/excelImport.js';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"cleanup": "rm -rf .turbo && rm -rf node_modules"
|
"cleanup": "rm -rf .turbo && rm -rf node_modules"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"deepmerge-ts": "^5.1.0",
|
||||||
"luxon": "^3.3.0",
|
"luxon": "^3.3.0",
|
||||||
"nanoid": "^4.0.1"
|
"nanoid": "^4.0.1"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// evaluating the library, we re-export to make it easy to detach
|
||||||
|
export { deepmerge } from 'deepmerge-ts';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type ExcelImportOptions = keyof typeof defaultExcelImportMap;
|
||||||
|
export type ExcelImportMap = typeof defaultExcelImportMap;
|
||||||
|
|
||||||
|
export const defaultExcelImportMap = {
|
||||||
|
worksheet: 'event schedule',
|
||||||
|
projectName: 'project name',
|
||||||
|
projectDescription: 'project description',
|
||||||
|
publicUrl: 'public url',
|
||||||
|
publicInfo: 'public info',
|
||||||
|
backstageUrl: 'backstage url',
|
||||||
|
backstageInfo: 'backstage info',
|
||||||
|
timeStart: 'time start',
|
||||||
|
timeEnd: 'time end',
|
||||||
|
duration: 'duration',
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
presenter: 'presenter',
|
||||||
|
subtitle: 'subtitle',
|
||||||
|
isPublic: 'public',
|
||||||
|
skip: 'skip',
|
||||||
|
note: 'notes',
|
||||||
|
colour: 'colour',
|
||||||
|
endAction: 'end action',
|
||||||
|
timerType: 'timer type',
|
||||||
|
user0: 'user0',
|
||||||
|
user1: 'user1',
|
||||||
|
user2: 'user2',
|
||||||
|
user3: 'user3',
|
||||||
|
user4: 'user4',
|
||||||
|
user5: 'user5',
|
||||||
|
user6: 'user6',
|
||||||
|
user7: 'user7',
|
||||||
|
user8: 'user8',
|
||||||
|
user9: 'user9',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isExcelImportMap(obj: unknown): obj is ExcelImportMap {
|
||||||
|
if (typeof obj !== 'object' || obj === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(obj);
|
||||||
|
return keys.every((key) => Object.hasOwn(defaultExcelImportMap, key));
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { OntimeRundown, SupportedEvent } from 'ontime-types';
|
|||||||
|
|
||||||
import { dayInMs } from '../timeConstants.js';
|
import { dayInMs } from '../timeConstants.js';
|
||||||
import { getNextEvent, getPreviousEvent } from './rundownUtils';
|
import { getNextEvent, getPreviousEvent } from './rundownUtils';
|
||||||
import { calculateDuration } from './rundownUtils.js';
|
|
||||||
|
|
||||||
describe('getNextEvent()', () => {
|
describe('getNextEvent()', () => {
|
||||||
it('returns the next event of type event', () => {
|
it('returns the next event of type event', () => {
|
||||||
@@ -71,25 +70,3 @@ describe('getPreviousEvent()', () => {
|
|||||||
expect(previous).toBe(null);
|
expect(previous).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateDuration()', () => {
|
|
||||||
describe('Given start and end values', () => {
|
|
||||||
it('is the difference between end and start', () => {
|
|
||||||
const duration = calculateDuration(10, 20);
|
|
||||||
expect(duration).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Handles edge cases', () => {
|
|
||||||
it('handles events that go over midnight', () => {
|
|
||||||
const duration = calculateDuration(51, 50);
|
|
||||||
expect(duration).toBe(dayInMs - 1);
|
|
||||||
});
|
|
||||||
it('handles no difference', () => {
|
|
||||||
const duration1 = calculateDuration(0, 0);
|
|
||||||
const duration2 = calculateDuration(dayInMs, dayInMs);
|
|
||||||
expect(duration1).toBe(0);
|
|
||||||
expect(duration2).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { dayInMs } from '../timeConstants.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets first event in rundown, if it exists
|
* Gets first event in rundown, if it exists
|
||||||
* @param {OntimeRundownEntry[]} rundown
|
* @param {OntimeRundownEntry[]} rundown
|
||||||
@@ -113,20 +111,6 @@ export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @description calculates event duration considering midnight
|
|
||||||
* @param {number} timeStart
|
|
||||||
* @param {number} timeEnd
|
|
||||||
* @returns {number}
|
|
||||||
*/
|
|
||||||
export const calculateDuration = (timeStart: number, timeEnd: number): number => {
|
|
||||||
// Durations must be positive
|
|
||||||
if (timeEnd < timeStart) {
|
|
||||||
return timeEnd + dayInMs - timeStart;
|
|
||||||
}
|
|
||||||
return timeEnd - timeStart;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description swaps two OntimeEvents in the rundown
|
* @description swaps two OntimeEvents in the rundown
|
||||||
* @param {OntimeRundown} rundown
|
* @param {OntimeRundown} rundown
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { EndAction, TimerType } from 'ontime-types';
|
||||||
|
import { expect } from 'vitest';
|
||||||
|
|
||||||
|
import { dayInMs } from '../timeConstants.js';
|
||||||
|
import { calculateDuration, validateEndAction, validateTimerType, validateTimes } from './validateEvent.js';
|
||||||
|
|
||||||
|
describe('validateEndAction()', () => {
|
||||||
|
it('recognises a string representation of an action', () => {
|
||||||
|
const endAction = validateEndAction('load-next');
|
||||||
|
expect(endAction).toBe(EndAction.LoadNext);
|
||||||
|
});
|
||||||
|
it('returns fallback otherwise', () => {
|
||||||
|
const emptyAction = validateEndAction('', EndAction.Stop);
|
||||||
|
const invalidAction = validateEndAction('this-does-not-exist', EndAction.PlayNext);
|
||||||
|
expect(emptyAction).toBe(EndAction.Stop);
|
||||||
|
expect(invalidAction).toBe(EndAction.PlayNext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTimerType()', () => {
|
||||||
|
it('recognises a string representation of an action', () => {
|
||||||
|
const timerType = validateTimerType('time-to-end');
|
||||||
|
expect(timerType).toBe(TimerType.TimeToEnd);
|
||||||
|
});
|
||||||
|
it('returns fallback otherwise', () => {
|
||||||
|
const emptyType = validateTimerType('', TimerType.Clock);
|
||||||
|
const invalidType = validateTimerType('this-does-not-exist', TimerType.CountDown);
|
||||||
|
expect(emptyType).toBe(TimerType.Clock);
|
||||||
|
expect(invalidType).toBe(TimerType.CountDown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateTimes()', () => {
|
||||||
|
it('passes through a well defined time list', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(5, 10, 5);
|
||||||
|
expect(timeStart).toBe(5);
|
||||||
|
expect(timeEnd).toBe(10);
|
||||||
|
expect(duration).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles cases when no times are given', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(null, undefined, null);
|
||||||
|
expect(timeStart).toBe(0);
|
||||||
|
expect(timeEnd).toBe(0);
|
||||||
|
expect(duration).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates duration', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(5, 10);
|
||||||
|
expect(timeStart).toBe(5);
|
||||||
|
expect(timeEnd).toBe(10);
|
||||||
|
expect(duration).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates end time', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(5, undefined, 10);
|
||||||
|
expect(timeStart).toBe(5);
|
||||||
|
expect(timeEnd).toBe(15);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles events that finish the day after', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(100, 10);
|
||||||
|
expect(timeStart).toBe(100);
|
||||||
|
expect(timeEnd).toBe(10);
|
||||||
|
expect(duration).toBe(dayInMs - 90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('corrects time in case of conflicts', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(5, 15, 15);
|
||||||
|
expect(timeStart).toBe(5);
|
||||||
|
expect(timeEnd).toBe(15);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates start time', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(undefined, 15, 10);
|
||||||
|
expect(timeStart).toBe(5);
|
||||||
|
expect(timeEnd).toBe(15);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates start and end time', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(undefined, undefined, 10);
|
||||||
|
expect(timeStart).toBe(0);
|
||||||
|
expect(timeEnd).toBe(10);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures values are integers', () => {
|
||||||
|
const { timeStart, timeEnd, duration } = validateTimes(0.000001, 10.312335342, 10);
|
||||||
|
expect(timeStart).toBe(0);
|
||||||
|
expect(timeEnd).toBe(10);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateDuration()', () => {
|
||||||
|
describe('Given start and end values', () => {
|
||||||
|
it('is the difference between end and start', () => {
|
||||||
|
const duration = calculateDuration(10, 20);
|
||||||
|
expect(duration).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Handles edge cases', () => {
|
||||||
|
it('handles events that go over midnight', () => {
|
||||||
|
const duration = calculateDuration(51, 50);
|
||||||
|
expect(duration).toBe(dayInMs - 1);
|
||||||
|
});
|
||||||
|
it('handles no difference', () => {
|
||||||
|
const duration1 = calculateDuration(0, 0);
|
||||||
|
const duration2 = calculateDuration(dayInMs, dayInMs);
|
||||||
|
expect(duration1).toBe(0);
|
||||||
|
expect(duration2).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { EndAction, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
import { dayInMs } from '../timeConstants.js';
|
||||||
|
|
||||||
|
export function validateEndAction(maybeAction: unknown, fallback = EndAction.None) {
|
||||||
|
if (typeof maybeAction !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAction = Object.values(EndAction).includes(maybeAction as EndAction);
|
||||||
|
if (isAction) {
|
||||||
|
return maybeAction as EndAction;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimerType(maybeTimerType: unknown, fallback = TimerType.CountDown) {
|
||||||
|
if (typeof maybeTimerType !== 'string') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTimerType = Object.values(TimerType).includes(maybeTimerType as TimerType);
|
||||||
|
if (isTimerType) {
|
||||||
|
return maybeTimerType as TimerType;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description calculates event duration considering midnight
|
||||||
|
* @param {number} timeStart
|
||||||
|
* @param {number} timeEnd
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export const calculateDuration = (timeStart: number, timeEnd: number): number => {
|
||||||
|
// Durations must be positive
|
||||||
|
if (timeEnd < timeStart) {
|
||||||
|
return timeEnd + dayInMs - timeStart;
|
||||||
|
}
|
||||||
|
return timeEnd - timeStart;
|
||||||
|
};
|
||||||
|
|
||||||
|
function convertToInteger(value: unknown): number {
|
||||||
|
const result = Number(value);
|
||||||
|
return isNaN(result) ? 0 : Math.floor(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimes(_start?: unknown, _end?: unknown, _duration?: unknown) {
|
||||||
|
const timeStart = convertToInteger(_start);
|
||||||
|
const timeEnd = convertToInteger(_end);
|
||||||
|
const duration = convertToInteger(_duration);
|
||||||
|
|
||||||
|
if (_start != null && _end != null) {
|
||||||
|
// Case 1. if we have start and end, duration must be derived
|
||||||
|
return { timeStart, duration: calculateDuration(timeStart, timeEnd), timeEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_start == null && _end == null) {
|
||||||
|
if (_duration == null) {
|
||||||
|
// Case 2. no valid times were given
|
||||||
|
return { timeStart, duration, timeEnd };
|
||||||
|
}
|
||||||
|
// Case 3. we have a duration and infer the rest
|
||||||
|
return { timeStart, duration, timeEnd: duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_start != null) {
|
||||||
|
// Case 5. with only start, we can calculate the rest
|
||||||
|
return { timeStart, duration, timeEnd: timeStart + duration };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_end != null) {
|
||||||
|
// Case 6. with only end, we can calculate the rest
|
||||||
|
return { timeStart: timeEnd - duration, duration, timeEnd };
|
||||||
|
}
|
||||||
|
|
||||||
|
// we should have covered all cases
|
||||||
|
return { timeStart, duration, timeEnd };
|
||||||
|
}
|
||||||
Generated
+32
-89
@@ -188,7 +188,7 @@ importers:
|
|||||||
lowdb: ^5.0.5
|
lowdb: ^5.0.5
|
||||||
multer: ^1.4.5-lts.1
|
multer: ^1.4.5-lts.1
|
||||||
node-osc: ^8.0.10
|
node-osc: ^8.0.10
|
||||||
node-xlsx: ^0.21.0
|
node-xlsx: ^0.23.0
|
||||||
nodemon: ^2.0.20
|
nodemon: ^2.0.20
|
||||||
ontime-types: workspace:*
|
ontime-types: workspace:*
|
||||||
ontime-utils: workspace:*
|
ontime-utils: workspace:*
|
||||||
@@ -211,7 +211,7 @@ importers:
|
|||||||
lowdb: 5.0.5
|
lowdb: 5.0.5
|
||||||
multer: 1.4.5-lts.1
|
multer: 1.4.5-lts.1
|
||||||
node-osc: 8.0.10
|
node-osc: 8.0.10
|
||||||
node-xlsx: 0.21.0
|
node-xlsx: 0.23.0
|
||||||
ontime-utils: link:../../packages/utils
|
ontime-utils: link:../../packages/utils
|
||||||
passport: 0.6.0
|
passport: 0.6.0
|
||||||
passport-local: 1.0.0
|
passport-local: 1.0.0
|
||||||
@@ -251,6 +251,7 @@ importers:
|
|||||||
'@types/luxon': ^3.2.0
|
'@types/luxon': ^3.2.0
|
||||||
'@typescript-eslint/eslint-plugin': ^5.48.1
|
'@typescript-eslint/eslint-plugin': ^5.48.1
|
||||||
'@typescript-eslint/parser': ^5.48.1
|
'@typescript-eslint/parser': ^5.48.1
|
||||||
|
deepmerge-ts: ^5.1.0
|
||||||
eslint: ^8.31.0
|
eslint: ^8.31.0
|
||||||
eslint-config-prettier: ^8.6.0
|
eslint-config-prettier: ^8.6.0
|
||||||
eslint-plugin-prettier: ^4.2.1
|
eslint-plugin-prettier: ^4.2.1
|
||||||
@@ -262,6 +263,7 @@ importers:
|
|||||||
typescript: ^4.9.4
|
typescript: ^4.9.4
|
||||||
vitest: ^0.30.1
|
vitest: ^0.30.1
|
||||||
dependencies:
|
dependencies:
|
||||||
|
deepmerge-ts: 5.1.0
|
||||||
luxon: 3.3.0
|
luxon: 3.3.0
|
||||||
nanoid: 4.0.1
|
nanoid: 4.0.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -3289,20 +3291,6 @@ packages:
|
|||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/adler-32/1.2.0:
|
|
||||||
resolution: {integrity: sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
exit-on-epipe: 1.0.1
|
|
||||||
printj: 1.1.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/adler-32/1.3.1:
|
|
||||||
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/agent-base/6.0.2:
|
/agent-base/6.0.2:
|
||||||
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
@@ -3751,14 +3739,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-ecER9xgJQVMqcrxThKptsW0pPxSae8R2RB87LNa+ivW9ppNWRHEplXcDzkCOP4LYWGj8hunXLqaiC41iBATNyg==}
|
resolution: {integrity: sha512-ecER9xgJQVMqcrxThKptsW0pPxSae8R2RB87LNa+ivW9ppNWRHEplXcDzkCOP4LYWGj8hunXLqaiC41iBATNyg==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/cfb/1.2.2:
|
|
||||||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dependencies:
|
|
||||||
adler-32: 1.3.1
|
|
||||||
crc-32: 1.2.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/chai/4.3.7:
|
/chai/4.3.7:
|
||||||
resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==}
|
resolution: {integrity: sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3812,7 +3792,7 @@ packages:
|
|||||||
normalize-path: 3.0.0
|
normalize-path: 3.0.0
|
||||||
readdirp: 3.6.0
|
readdirp: 3.6.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/chownr/2.0.0:
|
/chownr/2.0.0:
|
||||||
@@ -3854,11 +3834,6 @@ packages:
|
|||||||
mimic-response: 1.0.1
|
mimic-response: 1.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/codepage/1.15.0:
|
|
||||||
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/color-convert/1.9.3:
|
/color-convert/1.9.3:
|
||||||
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -4024,12 +3999,6 @@ packages:
|
|||||||
path-type: 4.0.0
|
path-type: 4.0.0
|
||||||
yaml: 1.10.2
|
yaml: 1.10.2
|
||||||
|
|
||||||
/crc-32/1.2.2:
|
|
||||||
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
hasBin: true
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/crc/3.8.0:
|
/crc/3.8.0:
|
||||||
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==}
|
||||||
requiresBuild: true
|
requiresBuild: true
|
||||||
@@ -4189,6 +4158,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/deepmerge-ts/5.1.0:
|
||||||
|
resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==}
|
||||||
|
engines: {node: '>=16.0.0'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/deepmerge/4.3.0:
|
/deepmerge/4.3.0:
|
||||||
resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==}
|
resolution: {integrity: sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4860,11 +4834,6 @@ packages:
|
|||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/exit-on-epipe/1.0.1:
|
|
||||||
resolution: {integrity: sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/expect/29.3.1:
|
/expect/29.3.1:
|
||||||
resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==}
|
resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==}
|
||||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||||
@@ -5102,11 +5071,6 @@ packages:
|
|||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/frac/1.1.2:
|
|
||||||
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/framer-motion/10.11.2_biqbaboplfbrettd7655fr4n2y:
|
/framer-motion/10.11.2_biqbaboplfbrettd7655fr4n2y:
|
||||||
resolution: {integrity: sha512-IrwuC9regNOU99JoM/Z62CAMA3awGV6AcF7e3bcgXk/ZoNlGSt5aVq0J7UAwtLmCkwVlRvBkiMnvv2mZ1GW2pg==}
|
resolution: {integrity: sha512-IrwuC9regNOU99JoM/Z62CAMA3awGV6AcF7e3bcgXk/ZoNlGSt5aVq0J7UAwtLmCkwVlRvBkiMnvv2mZ1GW2pg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5183,6 +5147,14 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
/fsevents/2.3.3:
|
||||||
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
os: [darwin]
|
||||||
|
requiresBuild: true
|
||||||
|
dev: true
|
||||||
|
optional: true
|
||||||
|
|
||||||
/function-bind/1.1.1:
|
/function-bind/1.1.1:
|
||||||
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
|
resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
|
||||||
|
|
||||||
@@ -6287,12 +6259,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==}
|
resolution: {integrity: sha512-dFSmB8fFHEH/s81Xi+Y/15DQY6VHW81nXRj86EMSL3lmuTmK1e+aT4wrFCkTbm+gSwkw4KpX+rT/pMM2c1mF+A==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/node-xlsx/0.21.0:
|
/node-xlsx/0.23.0:
|
||||||
resolution: {integrity: sha512-MB+KcNCuRzwjgr17scpKiVTPd4Vbj3V+7QwKpqACGyJzhvC67xCQUbw2vYEIKtNfMfcLxgB2q2kEuRS8rmak9g==}
|
resolution: {integrity: sha512-r3KaSZSsSrK92rbPXnX/vDdxURmPPik0rjJ3A+Pybzpjyrk4G6WyGfj8JIz5dMMEpCmWVpmO4qoVPBxnpLv/8Q==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
xlsx: 0.17.5
|
xlsx: '@cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz'
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/nodemon/2.0.20:
|
/nodemon/2.0.20:
|
||||||
@@ -6648,12 +6620,6 @@ packages:
|
|||||||
react-is: 18.2.0
|
react-is: 18.2.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/printj/1.1.2:
|
|
||||||
resolution: {integrity: sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
hasBin: true
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/process-nextick-args/2.0.1:
|
/process-nextick-args/2.0.1:
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
dev: false
|
dev: false
|
||||||
@@ -7056,7 +7022,7 @@ packages:
|
|||||||
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/run-parallel/1.2.0:
|
/run-parallel/1.2.0:
|
||||||
@@ -7295,13 +7261,6 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/ssf/0.11.2:
|
|
||||||
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dependencies:
|
|
||||||
frac: 1.1.2
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/stack-utils/2.0.6:
|
/stack-utils/2.0.6:
|
||||||
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -8029,7 +7988,7 @@ packages:
|
|||||||
postcss: 8.4.21
|
postcss: 8.4.21
|
||||||
rollup: 3.20.7
|
rollup: 3.20.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/vite/4.3.1_c45e4f2cmsrvgdqihjqb2lwfbq:
|
/vite/4.3.1_c45e4f2cmsrvgdqihjqb2lwfbq:
|
||||||
@@ -8341,21 +8300,11 @@ packages:
|
|||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/wmf/1.0.2:
|
|
||||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/word-wrap/1.2.3:
|
/word-wrap/1.2.3:
|
||||||
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
|
resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/word/0.3.0:
|
|
||||||
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/wrap-ansi/7.0.0:
|
/wrap-ansi/7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -8395,20 +8344,6 @@ packages:
|
|||||||
optional: true
|
optional: true
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/xlsx/0.17.5:
|
|
||||||
resolution: {integrity: sha512-lXNU0TuYsvElzvtI6O7WIVb9Zar1XYw7Xb3VAx2wn8N/n0whBYrCnHMxtFyIiUU1Wjf09WzmLALDfBO5PqTb1g==}
|
|
||||||
engines: {node: '>=0.8'}
|
|
||||||
hasBin: true
|
|
||||||
dependencies:
|
|
||||||
adler-32: 1.2.0
|
|
||||||
cfb: 1.2.2
|
|
||||||
codepage: 1.15.0
|
|
||||||
crc-32: 1.2.2
|
|
||||||
ssf: 0.11.2
|
|
||||||
wmf: 1.0.2
|
|
||||||
word: 0.3.0
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/xml-name-validator/4.0.0:
|
/xml-name-validator/4.0.0:
|
||||||
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -8500,4 +8435,12 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
use-sync-external-store: 1.2.0_react@18.2.0
|
use-sync-external-store: 1.2.0_react@18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
'@cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz':
|
||||||
|
resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.19.3/xlsx-0.19.3.tgz}
|
||||||
|
name: xlsx
|
||||||
|
version: 0.19.3
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|||||||
Reference in New Issue
Block a user