mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 06:58:02 +00:00
Compare commits
28 Commits
| 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 |
@@ -21,6 +21,6 @@
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-console": ["warn", { "allow": ["warn", "error"]}]
|
||||
"no-console": "warn"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
build
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
|
||||
playwright-report
|
||||
|
||||
**/*.toml
|
||||
**/*.yml
|
||||
**/*.json
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
@@ -56,8 +56,6 @@ E2E tests are in a separate package. On running, [playwright](https://playwright
|
||||
webserver to test against
|
||||
These tests also run against a separate version of the DB (test-db)
|
||||
|
||||
Before running the E2E, you should first build the project with `pnpm build:local`.
|
||||
|
||||
You can run playwright tests from project root with `pnpm e2e`
|
||||
|
||||
When writing tests, it can be handy to run playwright in interactive mode with `pnpm e2e:i`. You would need to manually
|
||||
|
||||
@@ -14,11 +14,10 @@
|
||||
|
||||
Ontime is an application for creating and managing event running order and timers.
|
||||
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
The user inputs a list of events along with scheduling and event information.
|
||||
This will then populate a series of screens which are available to be rendered by any device in the Network.
|
||||
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video
|
||||
outputs.
|
||||
This makes for a simple and cheap way to distribute over a venue using a network infrastructure instead of video outputs.
|
||||
|
||||

|
||||
|
||||
@@ -29,7 +28,7 @@ outputs.
|
||||
Once installed and running, Ontime starts a background server that is the heart of all processes.
|
||||
From the app, you can add / edit your running order and control the timer playback.
|
||||
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
Any device with a browser in the same network can choose one of the supported views to render the available data.
|
||||
This is done by reaching the ontime server at the _default port 4001_ eg: `localhost:4001`
|
||||
or `192.168.1.3:4001`
|
||||
<br />
|
||||
@@ -51,9 +50,8 @@ IP.ADDRESS:4001/public > Public / Foyer view
|
||||
IP.ADDRESS:4001/lower > Lower Thirds
|
||||
IP.ADDRESS:4001/studio > Studio Clock
|
||||
```
|
||||
|
||||
```
|
||||
For management views
|
||||
For management views
|
||||
-------------------------------------------------------------
|
||||
IP.ADDRESS:4001/editor > the control interface, same as the app
|
||||
IP.ADDRESS:4001/cuesheet > realtime cuesheets for collaboration
|
||||
@@ -65,14 +63,14 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
|
||||
- [x] Distribute data over network and render it in the browser
|
||||
- [x] Different screen types
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- Stage Timer
|
||||
- Minimal Timer
|
||||
- Clock
|
||||
- Backstage Info
|
||||
- Public Info
|
||||
- Studio Clock
|
||||
- Countdown
|
||||
- [Make your own?](#make-your-own-viewer)
|
||||
- [x] Configurable Lower Thirds
|
||||
- [x] Collaborative editing with the cuesheet view
|
||||
- [x] Send live messages to different screen types
|
||||
@@ -85,19 +83,17 @@ More documentation is available [in our docs](https://ontime.gitbook.io)
|
||||
- [x] Roll mode: run standalone using the system clock
|
||||
- [x] [Headless run](#headless-run): run server in a separate machine, configure from a browser locally
|
||||
- [x] [Countdown to anything!](https://ontime.gitbook.io/v2/views/countdown): have
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
a countdown to any scheduled event
|
||||
- [x] Multi-platform (available on Windows, MacOS and Linux)
|
||||
- [x] [Companion integration](https://bitfocus.io/connections/getontime-ontime)
|
||||
|
||||
## Unopinionated
|
||||
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
We want Ontime to be unique by targeting freelancers instead of roles.
|
||||
|
||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your
|
||||
many environments and workflows.
|
||||
We believe most freelancers work in different fields and we want to give you a tool that you can leverage across your many environments and workflows.
|
||||
|
||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to
|
||||
work.
|
||||
We are not interested in forcing workflows and have made Ontime so, it is flexible to whichever way you would like to work.
|
||||
|
||||
## Rich APIs for workflow integrations
|
||||
|
||||
@@ -117,8 +113,7 @@ Taking advantage of the integrations, we currently use Ontime with:
|
||||
|
||||
Ontime broadcasts its data over WebSockets. This allows you to consume its data outside the application.
|
||||
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language
|
||||
that can run in the browser).
|
||||
Writing a new view for the browser can be done with basic knowledge of HTML + CSS + Javascript (or any other language that can run in the browser).
|
||||
<br />
|
||||
See [this repository](https://github.com/cpvalente/ontime-viewer-template-v2) with a small template on
|
||||
how to get you started and read the docs about
|
||||
@@ -126,11 +121,26 @@ the [Websocket API](https://ontime.gitbook.io/v2/control-and-feedback/ontime-api
|
||||
|
||||
### Headless run️
|
||||
|
||||
You can self-host and run Ontime in a docker image.
|
||||
You can self-host and run Ontime in a docker image. The run command will:
|
||||
|
||||
The docker image along with documentation is [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
- expose the necessary ports (listed in the Dockerfile)
|
||||
- mount a local file to persist your data (in the example: ````$(pwd)/local-data````)
|
||||
- the image name __getontime/ontime__
|
||||
|
||||
If you want to run this image in a Raspberry Pi, please see [the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
The docker image is
|
||||
in [available Docker Hub at getontime/ontime](https://hub.docker.com/r/getontime/ontime)
|
||||
|
||||
```bash
|
||||
docker pull getontime/ontime
|
||||
```
|
||||
|
||||
and use the included docker compose to get started
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Related information available [in the docs](https://ontime.gitbook.io/v2/additional-notes/use-in-raspberry-pi)
|
||||
|
||||
## Roadmap
|
||||
|
||||
@@ -138,32 +148,32 @@ If you want to run this image in a Raspberry Pi, please see [the docs](https://o
|
||||
|
||||
Several features are planned in the roadmap, and we continuously adjust this to match how users interact with the app.
|
||||
<br />
|
||||
Have an idea? Reach out via [email](mail@getontime.no)
|
||||
or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
Have an idea? Reach out via [email](mail@getontime.no) or [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
### Issues
|
||||
|
||||
We use Github's issue tracking for bug reporting and feature requests. <br />
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
#### Unsigned App
|
||||
|
||||
When installing the app you would see warning screens from the Operating System like:
|
||||
|
||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||
```Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.```
|
||||
|
||||
or
|
||||
|
||||
`Ontime can't be opened because it is from an unidentified developer`
|
||||
```Ontime can't be opened because it is from an unidentified developer```
|
||||
|
||||
or in Linux
|
||||
|
||||
`Could Not Display "ontime-linux.AppImage`
|
||||
```Could Not Display "ontime-linux.AppImage```
|
||||
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click more and select "Run Anyway"
|
||||
- in macOS: the solution in macOS is different across versions, please refer to the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- in macOS: after attempting to run the installer, navigate to System Preferences -> Security &
|
||||
Privacy and allow the execution of the app
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
|
||||
@@ -175,7 +185,6 @@ please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
#### Safari
|
||||
|
||||
There are known issues with Safari versions lower than 13:
|
||||
|
||||
- Spacing and text styles might have small inconsistencies
|
||||
- Table view does not work
|
||||
|
||||
@@ -185,8 +194,7 @@ There is no plan for any further work on this.
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
<br /><br />
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
||||
Pull Request.
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a Pull Request.
|
||||
<br />
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "2.13.1",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.7.0",
|
||||
|
||||
@@ -2,18 +2,30 @@ import axios, { AxiosError } from 'axios';
|
||||
import { LogLevel } from 'ontime-types';
|
||||
import { generateId, millisToString } from 'ontime-utils';
|
||||
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
import { addLog } from '../stores/logger';
|
||||
import { nowInMillis } from '../utils/time';
|
||||
|
||||
export function logAxiosError(prepend: string, error: unknown) {
|
||||
let message;
|
||||
export function maybeAxiosError(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const statusText = (error as AxiosError).response?.statusText ?? '';
|
||||
const data = (error as AxiosError).response?.data ?? '';
|
||||
message = `${prepend} ${statusText}: ${data}`;
|
||||
let data = (error as AxiosError).response?.data ?? '';
|
||||
if (typeof data === 'object') {
|
||||
// TODO: use error instead, when migrated
|
||||
if ('message' in data) {
|
||||
data = JSON.stringify(data.message);
|
||||
} else {
|
||||
data = JSON.stringify(data);
|
||||
}
|
||||
}
|
||||
return `${statusText}: ${data}`;
|
||||
} else {
|
||||
message = `${prepend}: ${error}`;
|
||||
return error as string;
|
||||
}
|
||||
}
|
||||
|
||||
export function logAxiosError(prepend: string, error: unknown) {
|
||||
const message = `${prepend}: ${maybeAxiosError(error)}`;
|
||||
|
||||
addLog({
|
||||
id: generateId(),
|
||||
@@ -23,3 +35,16 @@ export function logAxiosError(prepend: string, error: unknown) {
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
|
||||
export async function invalidateAllCaches() {
|
||||
await ontimeQueryClient.invalidateQueries([
|
||||
'project',
|
||||
'aliases',
|
||||
'userFields',
|
||||
'rundown',
|
||||
'appinfo',
|
||||
'oscSettings',
|
||||
'appSettings',
|
||||
'viewSettings',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import axios from 'axios';
|
||||
import { Alias, OSCSettings, OscSubscription, ProjectData, Settings, UserFields, ViewSettings } from 'ontime-types';
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import {
|
||||
Alias,
|
||||
DatabaseModel,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
ProjectData,
|
||||
Settings,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import { ExcelImportMap } from 'ontime-utils';
|
||||
|
||||
import { apiRepoLatest } from '../../externals';
|
||||
import { InfoType } from '../models/Info';
|
||||
import fileDownload from '../utils/fileDownload';
|
||||
|
||||
import { ontimeURL } from './apiConstants';
|
||||
|
||||
@@ -110,30 +120,54 @@ export async function postOscSubscriptions(data: OscSubscription) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in CSV format
|
||||
* @description HTTP request to download db
|
||||
* @return {Promise}
|
||||
*/
|
||||
export const downloadCSV = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'csv' }, { type: 'text/csv;charset=utf-8;' });
|
||||
export const downloadRundown = async () => {
|
||||
await axios({
|
||||
url: `${ontimeURL}/db`,
|
||||
method: 'GET',
|
||||
responseType: 'blob', // important
|
||||
}).then((response) => {
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let filename = 'rundown.json';
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
filename = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to download db in JSON format
|
||||
*/
|
||||
export const downloadRundown = () => {
|
||||
return fileDownload(ontimeURL, { name: 'rundown', type: 'json' }, { type: 'application/json;charset=utf-8;' });
|
||||
// TODO: should this be extracted to shared code?
|
||||
export type ProjectFileImportOptions = {
|
||||
onlyRundown: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description HTTP request to upload events db
|
||||
* @return {Promise}
|
||||
*/
|
||||
type UploadDataOptions = {
|
||||
onlyRundown?: boolean;
|
||||
};
|
||||
export const uploadData = async (file: File, setProgress: (value: number) => void, options?: UploadDataOptions) => {
|
||||
export const uploadProjectFile = async (
|
||||
file: File,
|
||||
setProgress: (value: number) => void,
|
||||
options?: Partial<ProjectFileImportOptions>,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('userFile', file);
|
||||
const onlyRundown = options?.onlyRundown || 'false';
|
||||
|
||||
const onlyRundown = Boolean(options?.onlyRundown);
|
||||
console.log('debug here', onlyRundown, options);
|
||||
|
||||
await axios
|
||||
.post(`${ontimeURL}/db?onlyRundown=${onlyRundown}`, formData, {
|
||||
headers: {
|
||||
@@ -147,6 +181,47 @@ export const uploadData = async (file: File, setProgress: (value: number) => voi
|
||||
.then((response) => response.data.id);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Make patch changes to the objects in the db
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function patchData(patchDb: Partial<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 = {
|
||||
url: string;
|
||||
version: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
|
||||
import { EditorUpdateFields } from '../../../../features/event-editor/EventEditor';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
@@ -8,8 +8,8 @@ import style from './SwatchSelect.module.scss';
|
||||
|
||||
interface ColourInputProps {
|
||||
value: string;
|
||||
name: TitleActions;
|
||||
handleChange: (newValue: TitleActions, name: string) => void;
|
||||
name: EditorUpdateFields;
|
||||
handleChange: (newValue: EditorUpdateFields, name: string) => void;
|
||||
}
|
||||
|
||||
const colours = [
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import Empty from '../state/Empty';
|
||||
|
||||
import { useSchedule } from './ScheduleContext';
|
||||
import ScheduleItem from './ScheduleItem';
|
||||
|
||||
@@ -11,9 +13,8 @@ interface ScheduleProps {
|
||||
export default function Schedule({ isProduction, className }: ScheduleProps) {
|
||||
const { paginatedEvents, selectedEventId, isBackstage, scheduleType } = useSchedule();
|
||||
|
||||
// TODO: design a nice placeholder for empty schedules
|
||||
if (paginatedEvents?.length < 1) {
|
||||
return null;
|
||||
return <Empty text='No events to show' />;
|
||||
}
|
||||
|
||||
let selectedState: 'past' | 'now' | 'future' = 'past';
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createContext, PropsWithChildren, useContext, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useInterval } from '../../hooks/useInterval';
|
||||
import { isStringBoolean } from '../../utils/viewUtils';
|
||||
|
||||
interface ScheduleContextState {
|
||||
events: OntimeEvent[];
|
||||
@@ -34,25 +32,12 @@ export const ScheduleProvider = ({
|
||||
time = 10,
|
||||
}: PropsWithChildren<ScheduleProviderProps>) => {
|
||||
const [visiblePage, setVisiblePage] = useState(0);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// look for overrides from views
|
||||
const hidePast = isStringBoolean(searchParams.get('hidePast'));
|
||||
const stopCycle = isStringBoolean(searchParams.get('stopCycle'));
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const viewEvents = [...events];
|
||||
if (hidePast) {
|
||||
// we want to show the event after the next
|
||||
viewEvents.splice(0, selectedEventIndex + 2);
|
||||
selectedEventIndex = 0;
|
||||
}
|
||||
|
||||
const numPages = Math.ceil(viewEvents.length / eventsPerPage);
|
||||
const numPages = Math.ceil(events.length / eventsPerPage);
|
||||
const eventStart = eventsPerPage * visiblePage;
|
||||
const eventEnd = eventsPerPage * (visiblePage + 1);
|
||||
const paginatedEvents = viewEvents.slice(eventStart, eventEnd);
|
||||
const paginatedEvents = events.slice(eventStart, eventEnd);
|
||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
const resolveScheduleType = () => {
|
||||
if (selectedEventIndex >= eventStart && selectedEventIndex < eventEnd) {
|
||||
@@ -67,9 +52,7 @@ export const ScheduleProvider = ({
|
||||
|
||||
// every SCROLL_TIME go to the next array
|
||||
useInterval(() => {
|
||||
if (stopCycle) {
|
||||
setVisiblePage(0);
|
||||
} else if (events.length > eventsPerPage) {
|
||||
if (events.length > eventsPerPage) {
|
||||
const next = (visiblePage + 1) % numPages;
|
||||
setVisiblePage(next);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.noHover {
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
|
||||
@@ -132,7 +132,7 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
{
|
||||
id: 'hideovertime',
|
||||
title: 'Hide Overtime',
|
||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||
description: 'Whether to supress overtime styles (red borders and red text)',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
@@ -194,37 +194,6 @@ export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const BACKSTAGE_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
|
||||
export const PUBLIC_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
id: 'hidePast',
|
||||
title: 'Hide past events',
|
||||
description: 'Scheduler will only show upcoming events',
|
||||
type: 'boolean',
|
||||
},
|
||||
{
|
||||
id: 'stopCycle',
|
||||
title: 'Stop cycling through event pages',
|
||||
description: 'Schedule will not auto-cycle through events',
|
||||
type: 'boolean',
|
||||
},
|
||||
];
|
||||
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
||||
TIME_FORMAT_OPTION,
|
||||
{
|
||||
|
||||
@@ -47,7 +47,7 @@ export const getAliasRoute = (location: Location, data: Alias[], searchParams: U
|
||||
const aliasOnPage = searchParams.get('alias');
|
||||
for (const d of data) {
|
||||
if (aliasOnPage) {
|
||||
// if the alias fits the alias on this page, but the URL is different, we redirect user to the new URL
|
||||
// if the alias fits the alias on this page, but the URL is diferent, we redirect user to the new URL
|
||||
// if we have the same alias and its enabled and its not empty
|
||||
if (d.alias !== '' && d.enabled && d.alias === aliasOnPage) {
|
||||
const newAliasPath = resolvePath(d.pathAndParams);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { makeCSV, makeTable } from '../../features/cuesheet/cuesheetUtils';
|
||||
|
||||
type FileOptions = {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type BlobOptions = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export default async function fileDownload(url: string, fileOptions: FileOptions, blobOptions: BlobOptions) {
|
||||
const response = await axios({
|
||||
url: `${url}/db`,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
let { name: fileName } = fileOptions;
|
||||
const { type: fileType } = fileOptions;
|
||||
const { project, rundown, userFields } = response.data;
|
||||
|
||||
// try and get the filename from the response
|
||||
if (headerLine != null) {
|
||||
const startFileNameIndex = headerLine.indexOf('"') + 1;
|
||||
const endFileNameIndex = headerLine.lastIndexOf('"');
|
||||
fileName = headerLine.substring(startFileNameIndex, endFileNameIndex);
|
||||
}
|
||||
|
||||
let fileContent = '';
|
||||
|
||||
if (fileType === 'json') {
|
||||
fileContent = JSON.stringify(response.data);
|
||||
fileName += '.json';
|
||||
}
|
||||
|
||||
if (fileType === 'csv') {
|
||||
const sheetData = makeTable(project, rundown, userFields);
|
||||
fileContent = makeCSV(sheetData);
|
||||
fileName += '.csv';
|
||||
}
|
||||
|
||||
const blob = new Blob([fileContent], { type: blobOptions.type });
|
||||
const downloadUrl = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', downloadUrl);
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
return;
|
||||
}
|
||||
@@ -10,13 +10,13 @@ type ColourCombination = {
|
||||
* @param bgColour
|
||||
* @return {{backgroundColor, color: string}}
|
||||
*/
|
||||
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
||||
export const getAccessibleColour = (bgColour: string): ColourCombination => {
|
||||
if (bgColour) {
|
||||
try {
|
||||
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
||||
return { backgroundColor: bgColour, color: textColor };
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
} catch (error) {
|
||||
console.log(`Unable to parse colour: ${bgColour}`);
|
||||
}
|
||||
}
|
||||
return { backgroundColor: '#000', color: '#fffffa' };
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function MessageControl() {
|
||||
actionHandler={() => setMessage.presenterVisible(!data.timerMessage.visible)}
|
||||
/>
|
||||
<div className={style.buttonSection}>
|
||||
<label className={style.label}>Timer message blink</label>
|
||||
<label className={style.label}>Timer messsage blink</label>
|
||||
<label className={style.label}>Blackout timer screens</label>
|
||||
<Button
|
||||
className={`${data.timerMessage.timerBlink ? style.blink : ''}`}
|
||||
|
||||
@@ -44,11 +44,6 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: $gray-1300;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import Color from 'color';
|
||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||
@@ -118,14 +117,6 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
let rowBgColour: string | undefined;
|
||||
if (isSelected) {
|
||||
rowBgColour = 'var(--cuesheet-running-bg-override, #D20300)'; // $red-700
|
||||
} else if (row.original.colour) {
|
||||
try {
|
||||
// the colour is user defined and might be invalid
|
||||
const colour = new Color(row.original.colour).alpha(0.25);
|
||||
rowBgColour = colour.hsl().string();
|
||||
} catch (_error) {
|
||||
/* we do not handle errors here */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,4 +21,4 @@
|
||||
border: 1px solid $white-10;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { OntimeRundownEntry, ProjectData } from 'ontime-types';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
@@ -6,7 +6,6 @@ import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import { useCuesheet } from '../../common/hooks/useSocket';
|
||||
import useRundown from '../../common/hooks-query/useRundown';
|
||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||
|
||||
import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader';
|
||||
import Cuesheet from './Cuesheet';
|
||||
@@ -21,8 +20,6 @@ export default function CuesheetWrapper() {
|
||||
const { updateEvent } = useEventAction();
|
||||
const featureData = useCuesheet();
|
||||
const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [headerData, setheaderData] = useState<ProjectData | null>(null);
|
||||
|
||||
// Set window title
|
||||
useEffect(() => {
|
||||
@@ -72,76 +69,37 @@ export default function CuesheetWrapper() {
|
||||
);
|
||||
|
||||
const exportHandler = useCallback(
|
||||
(headerData: ProjectData, exportType: ExportType) => {
|
||||
(headerData: ProjectData) => {
|
||||
if (!headerData || !rundown || !userFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fileName = '';
|
||||
let url = '';
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
if (exportType === 'json') {
|
||||
const jsonContent = JSON.stringify({
|
||||
headerData,
|
||||
rundown,
|
||||
userFields,
|
||||
});
|
||||
|
||||
fileName = 'ontime export.json';
|
||||
|
||||
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else if (exportType === 'csv') {
|
||||
const sheetData = makeTable(headerData, rundown, userFields);
|
||||
const csvContent = makeCSV(sheetData);
|
||||
|
||||
fileName = 'ontime export.csv';
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
url = URL.createObjectURL(blob);
|
||||
} else {
|
||||
console.error('Invalid export type: ', exportType);
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', fileName);
|
||||
link.setAttribute('download', 'ontime export.csv');
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up the URL.createObjectURL to release resources
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
},
|
||||
[rundown, userFields],
|
||||
);
|
||||
|
||||
const onModalClose = (exportType?: ExportType) => {
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!exportType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerData) {
|
||||
exportHandler(headerData, exportType);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (projectData: ProjectData) => {
|
||||
setheaderData(projectData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
if (!rundown || !userFields) {
|
||||
return <Empty text='Loading...' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetTableHeader handleExport={handleOpenModal} featureData={featureData} />
|
||||
<CuesheetTableHeader handleCSVExport={exportHandler} featureData={featureData} />
|
||||
<Cuesheet data={rundown} columns={columns} handleUpdate={handleUpdate} selectedId={featureData.selectedEventId} />
|
||||
<ExportModal isOpen={isModalOpen} onClose={onModalClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const bgColour = colour;
|
||||
const textColour = getAccessibleColour(bgColour);
|
||||
const bgFallback = 'transparent';
|
||||
const bgColour = colour || bgFallback;
|
||||
const textColour = bgColour === bgFallback ? undefined : getAccessibleColour(bgColour);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
|
||||
@@ -16,7 +16,7 @@ import CuesheetTableHeaderTimers from './CuesheetTableHeaderTimers';
|
||||
import style from './CuesheetTableHeader.module.scss';
|
||||
|
||||
interface CuesheetTableHeaderProps {
|
||||
handleExport: (headerData: ProjectData) => void;
|
||||
handleCSVExport: (headerData: ProjectData) => void;
|
||||
featureData: {
|
||||
playback: Playback;
|
||||
selectedEventIndex: number | null;
|
||||
@@ -25,7 +25,7 @@ interface CuesheetTableHeaderProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function CuesheetTableHeader({ handleExport, featureData }: CuesheetTableHeaderProps) {
|
||||
export default function CuesheetTableHeader({ handleCSVExport, featureData }: CuesheetTableHeaderProps) {
|
||||
const followSelected = useCuesheetSettings((state) => state.followSelected);
|
||||
const showSettings = useCuesheetSettings((state) => state.showSettings);
|
||||
const toggleSettings = useCuesheetSettings((state) => state.toggleSettings);
|
||||
@@ -33,9 +33,9 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||
const { data: project } = useProjectData();
|
||||
|
||||
const exportProject = () => {
|
||||
const exportCsv = () => {
|
||||
if (project) {
|
||||
handleExport(project);
|
||||
handleCSVExport(project);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,9 +72,9 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
|
||||
{isFullScreen ? <IoContract /> : <IoExpand />}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown'>
|
||||
<span className={style.actionIcon} onClick={exportProject}>
|
||||
Export
|
||||
<Tooltip openDelay={tooltipDelayFast} label='Export rundown to CSV'>
|
||||
<span className={style.actionIcon} onClick={exportCsv}>
|
||||
CSV
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,15 @@ import { useCallback } from 'react';
|
||||
import { Textarea } from '@chakra-ui/react';
|
||||
|
||||
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
|
||||
|
||||
import { TitleActions } from './EventEditorDataLeft';
|
||||
import { EditorUpdateFields } from '../EventEditor';
|
||||
|
||||
import style from '../EventEditor.module.scss';
|
||||
|
||||
interface CountedTextAreaProps {
|
||||
field: TitleActions;
|
||||
field: EditorUpdateFields;
|
||||
label: string;
|
||||
initialValue: string;
|
||||
submitHandler: (field: TitleActions, value: string) => void;
|
||||
submitHandler: (field: EditorUpdateFields, value: string) => void;
|
||||
}
|
||||
|
||||
export default function CountedTextArea(props: CountedTextAreaProps) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
import { VStack } from '@chakra-ui/react';
|
||||
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
||||
@@ -10,12 +10,11 @@ import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline';
|
||||
import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline';
|
||||
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
|
||||
|
||||
import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi';
|
||||
import { downloadRundown } from '../../common/api/ontimeApi';
|
||||
import QuitIconBtn from '../../common/components/buttons/QuitIconBtn';
|
||||
import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn';
|
||||
import useElectronEvent from '../../common/hooks/useElectronEvent';
|
||||
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
|
||||
import ExportModal, { ExportType } from '../modals/export-modal/ExportModal';
|
||||
|
||||
import style from './MenuBar.module.scss';
|
||||
|
||||
@@ -101,22 +100,6 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
};
|
||||
}, [handleKeyPress, isElectron]);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const onModalClose = (exportType?: ExportType) => {
|
||||
setIsModalOpen(false);
|
||||
|
||||
if (!exportType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (exportType === 'json') {
|
||||
downloadRundown();
|
||||
} else if (exportType === 'csv') {
|
||||
downloadCSV();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
<QuitIconBtn disabled={!isElectron} clickHandler={sendShutdown} size='md' />
|
||||
@@ -145,14 +128,12 @@ const MenuBar = (props: MenuBarProps) => {
|
||||
{...buttonStyle}
|
||||
icon={<IoSaveOutline />}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
clickHandler={() => setIsModalOpen(true)}
|
||||
clickHandler={downloadRundown}
|
||||
tooltip='Export project file'
|
||||
aria-label='Export project file'
|
||||
size='sm'
|
||||
/>
|
||||
|
||||
<ExportModal onClose={onModalClose} isOpen={isModalOpen} />
|
||||
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
|
||||
@@ -41,8 +41,8 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
||||
.title {
|
||||
font-size: $inner-section-text-size;
|
||||
color: $gray-500;
|
||||
padding-left: 8px;
|
||||
margin: 8px 0;
|
||||
padding-left: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,16 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
.success {
|
||||
@include subsection;
|
||||
color: $action-blue;
|
||||
}
|
||||
|
||||
.feedbackSection {
|
||||
justify-content: flex-start;
|
||||
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
margin-top: $section-spacing;
|
||||
display: flex;
|
||||
@@ -117,6 +127,10 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.vSpacer {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.shiftRight {
|
||||
align-self: flex-end;
|
||||
}
|
||||
@@ -135,6 +149,12 @@ $el-padding-with-compensation: 24px; // 16 + 8
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.twoEqualColumn {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.padBottom {
|
||||
padding-bottom: $element-spacing;
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.modalHeader {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
|
||||
|
||||
import styles from './ExportModal.module.scss';
|
||||
|
||||
export type ExportType = 'csv' | 'json';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: (type?: ExportType) => void;
|
||||
}
|
||||
|
||||
export default function ExportModal(props: ExportModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} motionPreset='scale' size='xl' colorScheme='blackAlpha'>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader className={styles.modalHeader}>Download options</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={styles.modalBody}>
|
||||
<Button onClick={() => onClose('csv')} variant='ontime-subtle-on-light' width='48%'>
|
||||
Rundown as CSV
|
||||
</Button>
|
||||
<Button onClick={() => onClose('json')} variant='ontime-filled' width='48%'>
|
||||
Project file
|
||||
</Button>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.uploadArea {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
border: 2px dashed $gray-50;
|
||||
max-width: 550px;
|
||||
|
||||
min-height: 150px;
|
||||
border: 2px dashed $gray-200;
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
transition-property: background-color;
|
||||
transition-duration: $transition-time-action;
|
||||
font-size: calc(1rem - 1px);
|
||||
|
||||
&:hover {
|
||||
border: 2px solid $blue-500;
|
||||
background-color: $blue-50;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.comment {
|
||||
color: gray;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadedItem {
|
||||
background-color: $gray-50;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"icon title close"
|
||||
"icon info ."
|
||||
"progress progress progress";
|
||||
grid-template-columns: auto 1fr auto;
|
||||
column-gap: 16px;
|
||||
border-radius: 3px;
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
grid-area: icon;
|
||||
font-size: 32px;
|
||||
color: $gray-700;
|
||||
}
|
||||
|
||||
.fileTitle {
|
||||
grid-area: title;
|
||||
font-size: 14px;
|
||||
color: $gray-1350;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
grid-area: info;
|
||||
font-size: 12px;
|
||||
color: $gray-1100;
|
||||
}
|
||||
|
||||
.fileProgress {
|
||||
grid-area: progress;
|
||||
}
|
||||
|
||||
.cancelUpload {
|
||||
grid-area: close;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.error {
|
||||
.icon {
|
||||
color: $error-red;
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
.icon {
|
||||
color: $green-500;
|
||||
}
|
||||
color: $modal-note-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,3 +41,9 @@
|
||||
.pad {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.twoColumn {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ChangeEvent, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
@@ -9,22 +8,33 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Progress,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
import { IoClose } from '@react-icons/all-files/io5/IoClose';
|
||||
import { IoDocumentTextOutline } from '@react-icons/all-files/io5/IoDocumentTextOutline';
|
||||
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN_TABLE } from '../../../common/api/apiConstants';
|
||||
import { uploadData } from '../../../common/api/ontimeApi';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import ModalSplitInput from '../ModalSplitInput';
|
||||
import { invalidateAllCaches, maybeAxiosError } from '../../../common/api/apiUtils';
|
||||
import {
|
||||
patchData,
|
||||
postPreviewExcel,
|
||||
ProjectFileImportOptions,
|
||||
uploadProjectFile,
|
||||
} from '../../../common/api/ontimeApi';
|
||||
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||
|
||||
import { validateFile } from './utils';
|
||||
import PreviewExcel from './preview/PreviewExcel';
|
||||
import ExcelFileOptions from './upload-options/ExcelFileOptions';
|
||||
import OntimeFileOptions from './upload-options/OntimeFileOptions';
|
||||
import UploadStepTracker from './upload-step/UploadStep';
|
||||
import UploadFile from './UploadFile';
|
||||
import { useUploadModalContextStore } from './uploadModalContext';
|
||||
import { isExcelFile, isOntimeFile } from './uploadUtils';
|
||||
|
||||
import style from './UploadModal.module.scss';
|
||||
import { PROJECT_DATA, RUNDOWN_TABLE, USERFIELDS } from '../../../common/api/apiConstants';
|
||||
|
||||
export type UploadStep = 'upload' | 'review';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
@@ -33,64 +43,130 @@ interface UploadModalProps {
|
||||
|
||||
export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
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 fileUploaded = event?.target?.files?.[0];
|
||||
if (!fileUploaded) return;
|
||||
const { file, setProgress, clear } = useUploadModalContextStore();
|
||||
|
||||
const validate = validateFile(fileUploaded);
|
||||
setErrors(validate.errors?.[0]);
|
||||
const [uploadStep, setUploadStep] = useState<UploadStep>('upload');
|
||||
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) {
|
||||
setFile(fileUploaded);
|
||||
} else {
|
||||
setFile(null);
|
||||
}
|
||||
}, []);
|
||||
const [errors, setErrors] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
const ontimeFileOptions = useRef<Partial<ProjectFileImportOptions>>({});
|
||||
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) {
|
||||
setSubmitting(true);
|
||||
setErrors('');
|
||||
try {
|
||||
const options = {
|
||||
onlyRundown: overrideOptionRef.current?.checked || false,
|
||||
};
|
||||
await uploadData(file, setProgress, options);
|
||||
if (isOntimeFile(file)) {
|
||||
// TODO: we would also like to have preview for ontime project files
|
||||
const options = ontimeFileOptions.current;
|
||||
await handleOntimeFile(file, options);
|
||||
doClose = true;
|
||||
} else if (isExcelFile(file)) {
|
||||
const options = excelFileOptions.current;
|
||||
await handleExcelFile(file, options);
|
||||
await invalidateAllCaches();
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(`Failed uploading file: ${error}`);
|
||||
const message = maybeAxiosError(error);
|
||||
setErrors(`Failed uploading file ${message}`);
|
||||
} finally {
|
||||
await queryClient.invalidateQueries(RUNDOWN_TABLE);
|
||||
setSuccess(true);
|
||||
setSubmitting(false);
|
||||
if (doClose) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
}, [emitError, file, queryClient]);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const clearFile = () => {
|
||||
setFile(null);
|
||||
// when we upload excel, we populate state with preview data
|
||||
async function handleExcelFile(file: File, options: ExcelImportMap) {
|
||||
const response = await postPreviewExcel(file, setProgress, options);
|
||||
if (response.status === 200) {
|
||||
setRundown(response.data.rundown);
|
||||
setUserFields(response.data.userFields);
|
||||
setProject(response.data.project);
|
||||
// in excel imports we have an extra review step
|
||||
setUploadStep('review');
|
||||
}
|
||||
}
|
||||
|
||||
// when we upload project files, no extra operations are done
|
||||
async function handleOntimeFile(file: File, options: Partial<ProjectFileImportOptions>) {
|
||||
await uploadProjectFile(file, setProgress, options);
|
||||
}
|
||||
};
|
||||
|
||||
// before closing the modal, we clear data from mutations
|
||||
const handleClose = () => {
|
||||
clearFile();
|
||||
setSuccess(false);
|
||||
setErrors(undefined);
|
||||
setProgress(0);
|
||||
clear();
|
||||
setRundown([]);
|
||||
setUserFields(userFieldsPlaceholder);
|
||||
setProject(projectDataPlaceholder);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const disableSubmit = !file || isSubmitting;
|
||||
const handleFinalise = async () => {
|
||||
// this step is currently only used for excel files, after preview
|
||||
if (isExcel && rundown && userFields && project) {
|
||||
let doClose = false;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await patchData({ rundown, userFields, project });
|
||||
queryClient.setQueryData(RUNDOWN_TABLE, rundown);
|
||||
queryClient.setQueryData(USERFIELDS, userFields);
|
||||
queryClient.setQueryData(PROJECT_DATA, project);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [...RUNDOWN_TABLE, ...USERFIELDS, ...PROJECT_DATA],
|
||||
});
|
||||
doClose = true;
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setErrors(`Failed applying changes ${message}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
if (doClose) {
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const undoReview = () => {
|
||||
setUploadStep('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 (
|
||||
<Modal
|
||||
@@ -101,66 +177,51 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) {
|
||||
size='xl'
|
||||
scrollBehavior='inside'
|
||||
preserveScrollBarGap
|
||||
variant='ontime-small'
|
||||
variant='ontime-upload'
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>File import</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody className={style.uploadBody}>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleFile}
|
||||
accept='.json, .xlsx'
|
||||
data-testid='file-input'
|
||||
/>
|
||||
<div className={style.uploadArea} onClick={handleClick}>
|
||||
Click to upload Ontime project file
|
||||
</div>
|
||||
{file && (
|
||||
<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>
|
||||
{isExcel && <UploadStepTracker uploadStep={uploadStep} />}
|
||||
{uploadStep === 'upload' ? (
|
||||
<>
|
||||
<UploadFile />
|
||||
{isOntime && <OntimeFileOptions optionsRef={ontimeFileOptions} />}
|
||||
{isExcel && <ExcelFileOptions optionsRef={excelFileOptions} />}
|
||||
</>
|
||||
) : (
|
||||
<PreviewExcel
|
||||
rundown={rundown ?? []}
|
||||
project={project ?? projectDataPlaceholder}
|
||||
userFields={userFields ?? userFieldsPlaceholder}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
<ModalFooter className={`${style.buttonSection} ${style.pad}`}>
|
||||
<Button onClick={handleClose} isDisabled={isSubmitting} variant='ontime-ghost-on-light' size='sm'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
<ModalFooter>
|
||||
<div className={style.feedbackSection}>{errors && <div className={style.error}>{errors}</div>}</div>
|
||||
|
||||
<div className={`${style.buttonSection} ${style.pad}`}>
|
||||
<Button
|
||||
onClick={handleGoBack}
|
||||
isDisabled={disableGoBack || submitting}
|
||||
variant='ontime-ghost-on-light'
|
||||
size='sm'
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
isLoading={submitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
{submitText}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -175,9 +175,6 @@ $skip-opacity: 0.1;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $block-text-color;
|
||||
line-height: calc(1rem - 3px);
|
||||
|
||||
max-height: 2rem;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/* eslint-disable react/display-name */
|
||||
import { ComponentType, useMemo } from 'react';
|
||||
import { SupportedEvent } from 'ontime-types';
|
||||
import { useStore } from 'zustand';
|
||||
|
||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||
@@ -21,7 +20,7 @@ const withData = <P extends object>(Component: ComponentType<P>) => {
|
||||
|
||||
const publicEvents = useMemo(() => {
|
||||
if (Array.isArray(rundownData)) {
|
||||
return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic);
|
||||
return rundownData.filter((e) => e.type === 'event' && e.title && e.isPublic);
|
||||
}
|
||||
return [];
|
||||
}, [rundownData]);
|
||||
|
||||
@@ -89,10 +89,7 @@
|
||||
border-radius: 8px;
|
||||
|
||||
&.blink {
|
||||
animation-name: blink;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: 3;
|
||||
animation-duration: 1s;
|
||||
animation: blink 0.5s ease-in-out 3;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +99,7 @@
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.timer-gap {
|
||||
flex: 1;
|
||||
max-width: 7.5em;
|
||||
gap: 7.5em;
|
||||
}
|
||||
|
||||
.aux-timers {
|
||||
@@ -166,12 +159,9 @@
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
50% {
|
||||
background-color: var(--card-background-color-blink-override, $playback-start);
|
||||
}
|
||||
100% {
|
||||
20% {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
@@ -92,7 +92,7 @@ export default function Backstage(props: BackstageProps) {
|
||||
return (
|
||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
@@ -130,12 +130,10 @@ export default function Backstage(props: BackstageProps) {
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.started_at')}</div>
|
||||
<div className='aux-timers__value'>{startedAt}</div>
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.expected_finish')}</div>
|
||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||
</div>
|
||||
<div className='timer-gap' />
|
||||
<div className='aux-timers'>
|
||||
<div className='aux-timers__label'>{getLocalizedString('common.stage_timer')}</div>
|
||||
<div className='aux-timers__value'>{stageTimer}</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
|
||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||
@@ -58,7 +58,7 @@ export default function Public(props: BackstageProps) {
|
||||
return (
|
||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||
<NavigationMenu />
|
||||
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
|
||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
||||
<div className='project-header'>
|
||||
{general.title}
|
||||
<div className='clock-container'>
|
||||
|
||||
@@ -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: {
|
||||
fontWeight: 400,
|
||||
letterSpacing: '0.3px',
|
||||
padding: '16px 24px',
|
||||
fontSize: '20px',
|
||||
padding: '1rem 1.5rem',
|
||||
fontSize: '1.25rem',
|
||||
color: '#202020', // $gray-50
|
||||
},
|
||||
dialog: {
|
||||
@@ -20,17 +20,29 @@ export const ontimeModal = {
|
||||
color: '#202020', // $gray-50
|
||||
},
|
||||
footer: {
|
||||
padding: '8px',
|
||||
padding: '0.5rem',
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeSmallModal = {
|
||||
...ontimeModal,
|
||||
body: {
|
||||
padding: '16px',
|
||||
fontSize: '14px',
|
||||
padding: '1rem',
|
||||
fontSize: 'calc(1rem - 2px)',
|
||||
},
|
||||
dialog: {
|
||||
minHeight: 'min(200px, 10vh)',
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeUploadModal = {
|
||||
...ontimeSmallModal,
|
||||
body: {
|
||||
padding: '1rem',
|
||||
fontSize: 'calc(1rem - 2px)',
|
||||
},
|
||||
dialog: {
|
||||
minHeight: 'min(200px, 10vh)',
|
||||
maxWidth: 'min(800px, 80vh)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
import { ontimeCheckboxOnDark } from './ontimeCheckbox';
|
||||
import { ontimeEditable } from './ontimeEditable';
|
||||
import { ontimeMenuOnDark } from './ontimeMenu';
|
||||
import { ontimeModal, ontimeSmallModal } from './ontimeModal';
|
||||
import { ontimeModal, ontimeSmallModal, ontimeUploadModal } from './ontimeModal';
|
||||
import { ontimeProgressGray } from './OntimeProgress';
|
||||
import { ontimeBlockRadio } from './ontimeRadio';
|
||||
import { ontimeSelect } from './ontimeSelect';
|
||||
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
|
||||
@@ -79,6 +80,12 @@ const theme = extendTheme({
|
||||
variants: {
|
||||
ontime: { ...ontimeModal },
|
||||
'ontime-small': { ...ontimeSmallModal },
|
||||
'ontime-upload': { ...ontimeUploadModal },
|
||||
},
|
||||
},
|
||||
Progress: {
|
||||
variants: {
|
||||
'ontime-on-light': { ...ontimeProgressGray },
|
||||
},
|
||||
},
|
||||
Radio: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.13.1",
|
||||
"version": "2.9.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"dev:electron": "cross-env NODE_ENV=development electron .",
|
||||
"dev:electron": "NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
"dist-linux": "electron-builder --publish=never --x64 --linux",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 120
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "2.13.1",
|
||||
"version": "2.9.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"body-parser": "^1.20.0",
|
||||
@@ -14,18 +14,17 @@
|
||||
"express-validator": "^6.14.2",
|
||||
"lowdb": "^5.0.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"node-osc": "^9.0.2",
|
||||
"node-xlsx": "^0.21.0",
|
||||
"node-osc": "^8.0.10",
|
||||
"node-xlsx": "^0.23.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "~1.0.0",
|
||||
"steno": "^3.1.0",
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/node-osc": "^6.0.0",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^5.48.1",
|
||||
"@typescript-eslint/parser": "^5.48.1",
|
||||
@@ -53,8 +52,8 @@
|
||||
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
|
||||
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
|
||||
"lint": "eslint .",
|
||||
"test": "cross-env IS_TEST=true vitest",
|
||||
"test:pipeline": "cross-env IS_TEST=true vitest run",
|
||||
"test": "vitest",
|
||||
"test:pipeline": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
}
|
||||
|
||||
@@ -12,16 +12,16 @@ export class OscServer implements IAdapter {
|
||||
constructor(config: OSCSettings) {
|
||||
this.osc = new Server(config.portIn, '0.0.0.0');
|
||||
|
||||
this.osc.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
|
||||
this.osc.on('error', console.error);
|
||||
|
||||
this.osc.on('message', (msg) => {
|
||||
// message should look like /ontime/{path}/{params?} {args} where
|
||||
// message should look like /ontime/{path} {args} where
|
||||
// ontime: fixed message for app
|
||||
// path: command to be called
|
||||
// args: extra data, only used on some API entries (delay, goto)
|
||||
|
||||
// split message
|
||||
const [, address, path, ...params] = msg[0].split('/');
|
||||
const [, address, path] = msg[0].split('/');
|
||||
const args = msg[1];
|
||||
|
||||
// get first part before (ontime)
|
||||
@@ -37,14 +37,7 @@ export class OscServer implements IAdapter {
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = dispatchFromAdapter(
|
||||
path,
|
||||
{
|
||||
payload: args,
|
||||
params,
|
||||
},
|
||||
'osc',
|
||||
);
|
||||
const reply = dispatchFromAdapter(path, args, 'osc');
|
||||
if (reply) {
|
||||
const { topic, payload } = reply;
|
||||
this.osc.emit(topic, payload);
|
||||
|
||||
@@ -123,13 +123,7 @@ export class SocketServer implements IAdapter {
|
||||
|
||||
// Protocol specific stuff handled above
|
||||
try {
|
||||
const reply = dispatchFromAdapter(
|
||||
type,
|
||||
{
|
||||
payload,
|
||||
},
|
||||
'ws',
|
||||
);
|
||||
const reply = dispatchFromAdapter(type, payload, 'ws');
|
||||
if (reply) {
|
||||
const { topic, payload } = reply;
|
||||
ws.send(topic, payload);
|
||||
|
||||
+5
-26
@@ -1,5 +1,3 @@
|
||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
||||
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import expressStaticGzip from 'express-static-gzip';
|
||||
@@ -8,8 +6,10 @@ import cors from 'cors';
|
||||
|
||||
// import utils
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
||||
|
||||
// Import Routes
|
||||
import { router as rundownRouter } from './routes/rundownRouter.js';
|
||||
@@ -31,8 +31,6 @@ import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { populateStyles } from './modules/loadStyles.js';
|
||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||
import { PlaybackService } from './services/PlaybackService.js';
|
||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -139,21 +137,10 @@ export const startServer = async () => {
|
||||
expressServer = http.createServer(app);
|
||||
|
||||
socket.init(expressServer);
|
||||
eventLoader.init();
|
||||
|
||||
// load restore point if it exists
|
||||
const maybeRestorePoint = restoreService.load();
|
||||
|
||||
if (maybeRestorePoint) {
|
||||
logger.info(LogOrigin.Server, 'Found resumable state');
|
||||
PlaybackService.resume(maybeRestorePoint);
|
||||
}
|
||||
|
||||
eventTimer.setRestoreCallback(async (newState: RestorePoint) => restoreService.save(newState));
|
||||
|
||||
// provide initial payload to event store
|
||||
const initialPayload = getInitialPayload();
|
||||
eventStore.init(initialPayload);
|
||||
eventLoader.init();
|
||||
eventStore.init(getInitialPayload());
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
|
||||
@@ -200,7 +187,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
}
|
||||
|
||||
const { success, message } = oscIntegration.init(osc);
|
||||
logger.info(LogOrigin.Tx, message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
if (success) {
|
||||
integrationService.register(oscIntegration);
|
||||
@@ -215,14 +202,6 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
export const shutdown = async (exitCode = 0) => {
|
||||
console.log(`Ontime shutting down with code ${exitCode}`);
|
||||
|
||||
// clear the restore file if it was a normal exit
|
||||
// 0 means it was a SIGNAL
|
||||
// 1 means crash -> keep the file
|
||||
// 99 means it was the UI
|
||||
if (exitCode === 0 || exitCode === 99) {
|
||||
await restoreService.clear();
|
||||
}
|
||||
|
||||
expressServer?.close();
|
||||
oscServer?.shutdown();
|
||||
eventTimer.shutdown();
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
* Class Event Provider is a mediator for handling the local db
|
||||
* and adds logic specific to ontime data
|
||||
*/
|
||||
import { ProjectData, OntimeRundown, ViewSettings } from 'ontime-types';
|
||||
import {
|
||||
ProjectData,
|
||||
OntimeRundown,
|
||||
ViewSettings,
|
||||
DatabaseModel,
|
||||
OSCSettings,
|
||||
UserFields,
|
||||
Alias,
|
||||
Settings,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { data, db } from '../../modules/loadDb.js';
|
||||
import { safeMerge } from './DataProvider.utils.js';
|
||||
@@ -45,7 +54,7 @@ export class DataProvider {
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
static async setSettings(newData) {
|
||||
static async setSettings(newData: Settings) {
|
||||
data.settings = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
@@ -58,7 +67,7 @@ export class DataProvider {
|
||||
return data.aliases;
|
||||
}
|
||||
|
||||
static async setAliases(newData) {
|
||||
static async setAliases(newData: Alias[]) {
|
||||
data.aliases = newData;
|
||||
await this.persist();
|
||||
}
|
||||
@@ -76,12 +85,12 @@ export class DataProvider {
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static async setUserFields(newData) {
|
||||
static async setUserFields(newData: UserFields) {
|
||||
data.userFields = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
static async setOsc(newData) {
|
||||
static async setOsc(newData: OSCSettings) {
|
||||
data.osc = { ...newData };
|
||||
await this.persist();
|
||||
}
|
||||
@@ -95,7 +104,7 @@ export class DataProvider {
|
||||
await db.write();
|
||||
}
|
||||
|
||||
static async mergeIntoData(newData) {
|
||||
static async mergeIntoData(newData: Partial<DatabaseModel>) {
|
||||
const mergedData = safeMerge(data, newData);
|
||||
data.project = mergedData.project;
|
||||
data.settings = mergedData.settings;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { DatabaseModel } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Merges two data objects
|
||||
* @param {object} existing
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing, newData) {
|
||||
const { rundown, project, settings, viewSettings, osc, http, aliases, userFields } = newData || {};
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {};
|
||||
return {
|
||||
...existing,
|
||||
rundown: rundown ?? existing.rundown,
|
||||
@@ -32,6 +34,5 @@ export function safeMerge(existing, newData) {
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
http: { ...existing.http, ...http },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,11 +42,6 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
enabled: true,
|
||||
user: null,
|
||||
pwd: null,
|
||||
},
|
||||
};
|
||||
|
||||
it('returns existing data if new data is not provided', () => {
|
||||
@@ -188,19 +183,6 @@ describe('safeMerge', () => {
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
user: null,
|
||||
pwd: null,
|
||||
messages: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onUpdate: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onFinish: [],
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const newData = {
|
||||
|
||||
@@ -23,18 +23,6 @@ export class EventLoader {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
|
||||
instance = this;
|
||||
this.eventNow = null;
|
||||
this.publicEventNow = null;
|
||||
this.eventNext = null;
|
||||
this.publicEventNext = null;
|
||||
this.loaded = {
|
||||
selectedEventIndex: null,
|
||||
selectedEventId: null,
|
||||
selectedPublicEventId: null,
|
||||
nextEventId: null,
|
||||
nextPublicEventId: null,
|
||||
numEvents: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// we need to delay init until the store is ready
|
||||
@@ -83,7 +71,7 @@ export class EventLoader {
|
||||
* @param {string} eventId
|
||||
* @return {object | undefined}
|
||||
*/
|
||||
static getEventWithId(eventId): OntimeEvent | undefined {
|
||||
static getEventWithId(eventId) {
|
||||
const timedEvents = EventLoader.getTimedEvents();
|
||||
return timedEvents.find((event) => event.id === eventId);
|
||||
}
|
||||
@@ -180,10 +168,8 @@ export class EventLoader {
|
||||
this.loaded.selectedEventIndex = nowIndex;
|
||||
this.loaded.selectedEventId = currentEvent?.id || null;
|
||||
this.loaded.numEvents = timedEvents.length;
|
||||
this.loaded.nextEventId = nextEvent?.id || null;
|
||||
this.loaded.nextPublicEventId = nextPublicEvent?.id || null;
|
||||
|
||||
this._loadEvent();
|
||||
this.loaded.nextEventId = nextEvent.id;
|
||||
this.loaded.nextPublicEventId = nextPublicEvent.id;
|
||||
|
||||
return { currentEvent, nextEvent, timeToNext };
|
||||
}
|
||||
@@ -237,7 +223,7 @@ export class EventLoader {
|
||||
* loads an event given its id
|
||||
* @param {object} event
|
||||
*/
|
||||
loadEvent(event?: OntimeEvent) {
|
||||
loadEvent(event) {
|
||||
if (typeof event === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
@@ -283,7 +269,6 @@ export class EventLoader {
|
||||
// check if current is also public
|
||||
if (event.isPublic) {
|
||||
this.publicEventNow = event;
|
||||
this.loaded.selectedPublicEventId = event.id;
|
||||
} else {
|
||||
// assume there is no public event
|
||||
this.publicEventNow = null;
|
||||
|
||||
@@ -3,10 +3,10 @@ export const config = {
|
||||
testdb: 'test-db',
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
tablename: 'events',
|
||||
},
|
||||
styles: {
|
||||
directory: 'styles',
|
||||
filename: 'override.css',
|
||||
},
|
||||
restoreFile: 'ontime.restore',
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { LogOrigin, OntimeEvent } from 'ontime-types';
|
||||
import { EventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
import { editEvent } from '../services/rundown-service/RundownService.js';
|
||||
import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
const whitelistedPayload = {
|
||||
title: coerceString,
|
||||
subtitle: coerceString,
|
||||
presenter: coerceString,
|
||||
note: coerceString,
|
||||
cue: coerceString,
|
||||
|
||||
duration: coerceNumber,
|
||||
|
||||
isPublic: coerceBoolean,
|
||||
skip: coerceBoolean,
|
||||
|
||||
colour: coerceString,
|
||||
user0: coerceString,
|
||||
user1: coerceString,
|
||||
user2: coerceString,
|
||||
user3: coerceString,
|
||||
user4: coerceString,
|
||||
user5: coerceString,
|
||||
user6: coerceString,
|
||||
user7: coerceString,
|
||||
user8: coerceString,
|
||||
user9: coerceString,
|
||||
};
|
||||
|
||||
export function parse(field: string, value: unknown) {
|
||||
if (!whitelistedPayload.hasOwnProperty(field)) {
|
||||
throw new Error(`Field ${field} not permitted`);
|
||||
}
|
||||
const parserFn = whitelistedPayload[field];
|
||||
return parserFn(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a property of the event with the given id
|
||||
* @param {string} eventId
|
||||
* @param {keyof OntimeEvent} propertyName
|
||||
* @param {OntimeEvent[typeof propertyName]} newValue
|
||||
*/
|
||||
export function updateEvent(
|
||||
eventId: string,
|
||||
propertyName: keyof OntimeEvent,
|
||||
newValue: OntimeEvent[typeof propertyName],
|
||||
) {
|
||||
const event = EventLoader.getEventWithId(eventId);
|
||||
|
||||
if (event) {
|
||||
let propertiesToUpdate = { [propertyName]: newValue };
|
||||
|
||||
// Handles the special case for duration
|
||||
// needs to be converted to milliseconds
|
||||
if (propertyName === 'duration') {
|
||||
propertiesToUpdate.duration = (newValue as number) * 1000;
|
||||
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
|
||||
}
|
||||
|
||||
editEvent({ id: eventId, ...propertiesToUpdate }).then(() => {
|
||||
logger.info(LogOrigin.Playback, `Updated ${propertyName} of event with ID ${eventId} to ${newValue}`);
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Event with ID ${eventId} not found`);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,9 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { messageService } from '../services/message-service/MessageService.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { parse, updateEvent } from './integrationController.config.js';
|
||||
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
|
||||
import { event } from '../models/eventsDefinition.js';
|
||||
|
||||
//TODO: re-throwing the error does not add any extra information or value
|
||||
export function dispatchFromAdapter(
|
||||
type: string,
|
||||
args: {
|
||||
payload: unknown;
|
||||
params?: Array<string>;
|
||||
},
|
||||
source?: 'osc' | 'ws',
|
||||
) {
|
||||
const payload = args.payload;
|
||||
const typeComponents = type.toLowerCase().split('/');
|
||||
const mainType = typeComponents[0];
|
||||
const params = args.params || [];
|
||||
|
||||
switch (mainType) {
|
||||
export function dispatchFromAdapter(type: string, payload: unknown, source?: 'osc' | 'ws') {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'test-ontime': {
|
||||
return { topic: 'hello' };
|
||||
}
|
||||
@@ -175,19 +157,6 @@ export function dispatchFromAdapter(
|
||||
PlaybackService.roll();
|
||||
break;
|
||||
}
|
||||
case 'addtime': {
|
||||
const time = Number(payload);
|
||||
if (isNaN(time)) {
|
||||
throw new Error(`Time not recognised ${payload}`);
|
||||
}
|
||||
try {
|
||||
PlaybackService.addTime(time);
|
||||
} catch (error) {
|
||||
throw new Error(`Could not add time: ${error}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
//deprecated
|
||||
case 'delay': {
|
||||
const delayTime = Number(payload);
|
||||
if (isNaN(delayTime)) {
|
||||
@@ -253,23 +222,6 @@ export function dispatchFromAdapter(
|
||||
return { topic: 'timer', payload: timer };
|
||||
}
|
||||
|
||||
// ontime/change/{eventID}/{propertyName}
|
||||
case 'change': {
|
||||
if (params.length < 2) {
|
||||
throw new Error('Too few parameters, 3 expected');
|
||||
}
|
||||
if (payload === undefined) {
|
||||
throw new Error('No payload found');
|
||||
}
|
||||
const eventID = params[0];
|
||||
const propertyName = params[1] as keyof OntimeEvent;
|
||||
if (!isKeyOfType(propertyName, event)) {
|
||||
throw new Error(`Cannot update unknown event property ${propertyName}`);
|
||||
}
|
||||
const parsedPayload = parse(propertyName, payload);
|
||||
return updateEvent(eventID, propertyName, parsedPayload);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unhandled message ${type}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alias, LogOrigin, ProjectData } from 'ontime-types';
|
||||
import { Alias, DatabaseModel, LogOrigin, ProjectData } from 'ontime-types';
|
||||
|
||||
import { RequestHandler } from 'express';
|
||||
import fs from 'fs';
|
||||
@@ -7,13 +7,15 @@ import { networkInterfaces } from 'os';
|
||||
import { fileHandler } from '../utils/parser.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||
import { mergeObject } from '../utils/parserUtils.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { isDocker, resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents, forceReset } from '../services/rundown-service/RundownService.js';
|
||||
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
||||
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -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 req
|
||||
* @param res
|
||||
* @param [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndParse = async (file, req, res, options) => {
|
||||
if (!fs.existsSync(file)) {
|
||||
res.status(500).send({ message: 'Upload failed' });
|
||||
return;
|
||||
}
|
||||
const parseAndApply = async (file, _req, res, options) => {
|
||||
const result = await parseFile(file, _req, res, options);
|
||||
|
||||
try {
|
||||
const result = await fileHandler(file);
|
||||
PlaybackService.stop();
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
res.status(400).send({ message: result.message });
|
||||
} else if ('data' in result && result.message === 'success') {
|
||||
PlaybackService.stop();
|
||||
// explicitly write objects
|
||||
if (typeof result !== 'undefined') {
|
||||
const newRundown = result.data.rundown || [];
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result.data);
|
||||
}
|
||||
}
|
||||
forceReset();
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
res.status(400).send({ message: 'Failed parsing, no data' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
const newRundown = result.rundown || [];
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true, reset: true });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -169,7 +170,7 @@ export const postUserFields = async (req, res) => {
|
||||
}
|
||||
try {
|
||||
const persistedData = DataProvider.getUserFields();
|
||||
const newData = mergeObject(persistedData, req.body);
|
||||
const newData = deepmerge(persistedData, req.body);
|
||||
await DataProvider.setUserFields(newData);
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
@@ -207,16 +208,11 @@ export const postSettings = async (req, res) => {
|
||||
const settings = DataProvider.getSettings();
|
||||
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
|
||||
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
|
||||
const serverPort = Number(req.body?.serverPort);
|
||||
if (isNaN(serverPort)) {
|
||||
return res.status(400).send(`Invalid value found for server port: ${req.body?.serverPort}`);
|
||||
}
|
||||
|
||||
const hasChangedPort = settings.serverPort !== serverPort;
|
||||
|
||||
if (isDocker && hasChangedPort) {
|
||||
if (isDocker && req.body?.serverPort) {
|
||||
return res.status(403).json({ message: 'Can`t change port when running inside docker' });
|
||||
}
|
||||
const serverPort = parseInt(req.body?.serverPort ?? settings.serverPort, 10);
|
||||
|
||||
let timeFormat = settings.timeFormat;
|
||||
if (req.body?.timeFormat === '12' || req.body?.timeFormat === '24') {
|
||||
@@ -295,7 +291,7 @@ export const postOscSubscriptions = async (req, res) => {
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info(LogOrigin.Tx, message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
@@ -316,7 +312,7 @@ export const postOSC = async (req, res) => {
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info(LogOrigin.Tx, message);
|
||||
logger.info(LogOrigin.Rx, message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
@@ -324,8 +320,38 @@ export const postOSC = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/db'
|
||||
// Returns -
|
||||
export async function patchPartialProjectFile(req, res) {
|
||||
if (failEmptyObjects(req.body, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const patchDb: Partial<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) => {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
@@ -333,10 +359,39 @@ export const dbUpload = async (req, res) => {
|
||||
}
|
||||
const options = req.query;
|
||||
const file = req.file.path;
|
||||
await uploadAndParse(file, req, res, options);
|
||||
try {
|
||||
await parseAndApply(file, req, res, options);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
res.status(400).send({ message: `Failed parsing ${error}` });
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/new'
|
||||
/**
|
||||
* uploads and parses an excel file
|
||||
* @returns parsed result
|
||||
*/
|
||||
export async function previewExcel(req, res) {
|
||||
if (!req.file) {
|
||||
res.status(400).send({ message: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = JSON.parse(req.body.options);
|
||||
const file = req.file.path;
|
||||
const data = await parseFile(file, req, res, options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meant to create a new project file, it will clear only fields which are specific to a project
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const postNew: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
const newProjectData: ProjectData = {
|
||||
|
||||
@@ -118,3 +118,18 @@ export const validateOscSubscription = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
export const validatePatchProjectFile = [
|
||||
body('rundown').isArray().optional({ nullable: false }),
|
||||
body('project').isObject().optional({ nullable: false }),
|
||||
body('settings').isObject().optional({ nullable: false }),
|
||||
body('viewSettings').isObject().optional({ nullable: false }),
|
||||
body('aliases').isArray().optional({ nullable: false }),
|
||||
body('userFields').isObject().optional({ nullable: false }),
|
||||
body('osc').isObject().optional({ nullable: false }),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
|
||||
adapterToUse.data = dbModel;
|
||||
}
|
||||
|
||||
return parseJson(adapterToUse.data, true);
|
||||
return parseJson(adapterToUse.data);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getSettings,
|
||||
getUserFields,
|
||||
getViewSettings,
|
||||
patchPartialProjectFile,
|
||||
poll,
|
||||
postAliases,
|
||||
postNew,
|
||||
@@ -17,12 +18,14 @@ import {
|
||||
postSettings,
|
||||
postUserFields,
|
||||
postViewSettings,
|
||||
previewExcel,
|
||||
} from '../controllers/ontimeController.js';
|
||||
|
||||
import {
|
||||
validateAliases,
|
||||
validateOSC,
|
||||
validateOscSubscription,
|
||||
validatePatchProjectFile,
|
||||
validateSettings,
|
||||
validateUserFields,
|
||||
viewValidator,
|
||||
@@ -40,6 +43,12 @@ router.get('/db', dbDownload);
|
||||
// create route between controller and '/ontime/db' endpoint
|
||||
router.post('/db', uploadFile, dbUpload);
|
||||
|
||||
// create route between controller and '/ontime/excel' endpoint
|
||||
router.patch('/db', validatePatchProjectFile, patchPartialProjectFile);
|
||||
|
||||
// create route between controller and '/ontime/preview-spreadsheet' endpoint
|
||||
router.post('/preview-spreadsheet', uploadFile, previewExcel);
|
||||
|
||||
// create route between controller and '/ontime/settings' endpoint
|
||||
router.get('/settings', getSettings);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LogOrigin, OntimeEvent, Playback } from 'ontime-types';
|
||||
import { LogOrigin, OntimeEvent } from 'ontime-types';
|
||||
import { validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js';
|
||||
@@ -6,7 +6,6 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import { eventTimer } from './TimerService.js';
|
||||
import { clock } from './Clock.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { RestorePoint } from './RestoreService.js';
|
||||
|
||||
/**
|
||||
* Service manages playback status of app
|
||||
@@ -242,53 +241,17 @@ export class PlaybackService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description resume playback state given a restore point
|
||||
* @param restorePoint
|
||||
*/
|
||||
static resume(restorePoint: RestorePoint) {
|
||||
const willResume = () => logger.info(LogOrigin.Server, 'Resuming playback');
|
||||
|
||||
if (restorePoint.playback === Playback.Roll) {
|
||||
willResume();
|
||||
PlaybackService.roll();
|
||||
}
|
||||
|
||||
if (restorePoint.selectedEventId) {
|
||||
const event = EventLoader.getEventWithId(restorePoint.selectedEventId);
|
||||
// the db would have to change for the event not to exist
|
||||
// we do not kow the reason for the crash, so we check anyway
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
|
||||
eventLoader.loadEvent(event);
|
||||
eventTimer.resume(event, restorePoint);
|
||||
eventStore.broadcast();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds time to current event
|
||||
* @param {number} time - time to add in seconds
|
||||
*/
|
||||
static addTime(time: number) {
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const timeInMs = time * 1000;
|
||||
eventTimer.addTime(timeInMs);
|
||||
timeInMs > 0
|
||||
? logger.info(LogOrigin.Playback, `Added ${time} sec`)
|
||||
: logger.info(LogOrigin.Playback, `Removed ${time} sec`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds delay to current event
|
||||
* @deprecated Use addTime
|
||||
* @param {number} delayTime time in minutes
|
||||
*/
|
||||
static setDelay(delayTime: number) {
|
||||
this.addTime(delayTime * 60);
|
||||
if (eventTimer.loadedTimerId) {
|
||||
const delayInMs = delayTime * 1000 * 60;
|
||||
eventTimer.delay(delayInMs);
|
||||
delayInMs > 0
|
||||
? logger.info(LogOrigin.Playback, `Added ${delayTime} min delay`)
|
||||
: logger.info(LogOrigin.Playback, `Removed ${delayTime} min delay`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { Writer } from 'steno';
|
||||
|
||||
import { resolveRestoreFile } from '../setup.js';
|
||||
|
||||
export type RestorePoint = {
|
||||
playback: Playback;
|
||||
selectedEventId: string | null;
|
||||
startedAt: number | null;
|
||||
addedTime: number | null;
|
||||
pausedAt: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility validates a RestorePoint
|
||||
* @param obj
|
||||
* @return boolean
|
||||
*/
|
||||
export function isRestorePoint(obj: unknown): obj is RestorePoint {
|
||||
if (!obj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const restorePoint = obj as RestorePoint;
|
||||
|
||||
if (typeof restorePoint.playback !== 'string' || !Object.values(Playback).includes(restorePoint.playback)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.selectedEventId !== 'string' && restorePoint.selectedEventId !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.startedAt !== 'number' && restorePoint.startedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.addedTime !== 'number' && restorePoint.addedTime !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof restorePoint.pausedAt !== 'number' && restorePoint.pausedAt !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility interface to allow dependency injection during test
|
||||
*/
|
||||
|
||||
/**
|
||||
* Service manages saving of application state
|
||||
* that can then be restored when reopening
|
||||
*/
|
||||
export class RestoreService {
|
||||
private readonly filePath: string | null;
|
||||
|
||||
private lastStore: string | null;
|
||||
private file: Writer | null;
|
||||
private failedCreateAttempts: number;
|
||||
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath;
|
||||
|
||||
this.lastStore = null;
|
||||
this.file = null;
|
||||
this.failedCreateAttempts = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility, creates a restore file
|
||||
*/
|
||||
create() {
|
||||
this.file = new Writer(this.filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility, reads from file
|
||||
* @private
|
||||
*/
|
||||
private read() {
|
||||
return readFileSync(this.filePath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility writes payload to file
|
||||
* @throws
|
||||
* @param stringifiedState
|
||||
*/
|
||||
private async write(stringifiedState: string) {
|
||||
// Create a file if it doesnt exist
|
||||
if (!this.file) {
|
||||
this.create();
|
||||
}
|
||||
// steno is async, and it uses a queue to avoid unnecessary re-writes
|
||||
await this.file.write(stringifiedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves runtime data to restore file
|
||||
* @param newState RestorePoint
|
||||
*/
|
||||
async save(newState: RestorePoint) {
|
||||
// after three failed attempts, mark the service as unavailable
|
||||
if (this.failedCreateAttempts > 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stringifiedStore = JSON.stringify(newState);
|
||||
if (stringifiedStore !== this.lastStore) {
|
||||
try {
|
||||
await this.write(stringifiedStore);
|
||||
this.lastStore = stringifiedStore;
|
||||
this.failedCreateAttempts = 0;
|
||||
} catch (_err) {
|
||||
this.failedCreateAttempts += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts reading a restore point from a given file path
|
||||
* Returns null if none found, restore point otherwise
|
||||
*/
|
||||
load(): RestorePoint | null {
|
||||
try {
|
||||
const data = this.read();
|
||||
const maybeRestorePoint = JSON.parse(data);
|
||||
|
||||
if (!isRestorePoint(maybeRestorePoint)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return maybeRestorePoint;
|
||||
} catch (_error) {
|
||||
// no need to notify the user
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the restore file
|
||||
*/
|
||||
async clear() {
|
||||
if (this.file && this.failedCreateAttempts <= 3) {
|
||||
try {
|
||||
await this.file.write('');
|
||||
} catch (_error) {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
this.file = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const restoreService = new RestoreService(resolveRestoreFile);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, Playback, TimerLifeCycle, TimerState, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
@@ -8,7 +8,6 @@ import { integrationService } from './integration-service/IntegrationService.js'
|
||||
import { getCurrent, getExpectedFinish } from './timerUtils.js';
|
||||
import { clock } from './Clock.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import type { RestorePoint } from './RestoreService.js';
|
||||
|
||||
type initialLoadingData = {
|
||||
startedAt?: number | null;
|
||||
@@ -16,8 +15,6 @@ type initialLoadingData = {
|
||||
current?: number | null;
|
||||
};
|
||||
|
||||
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
|
||||
|
||||
export class TimerService {
|
||||
private readonly _interval: NodeJS.Timer;
|
||||
private _updateInterval: number;
|
||||
@@ -34,7 +31,6 @@ export class TimerService {
|
||||
private pausedAt: number | null;
|
||||
private secondaryTarget: number | null;
|
||||
|
||||
private saveRestorePoint: RestoreCallback;
|
||||
/**
|
||||
* @constructor
|
||||
* @param {object} [timerConfig]
|
||||
@@ -47,14 +43,6 @@ export class TimerService {
|
||||
this._updateInterval = timerConfig?.updateInterval ?? 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides callback to save restore point
|
||||
* @param cb
|
||||
*/
|
||||
setRestoreCallback(cb: RestoreCallback) {
|
||||
this.saveRestorePoint = cb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears internal state
|
||||
* @private
|
||||
@@ -86,44 +74,6 @@ export class TimerService {
|
||||
this._lastUpdate = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a given playback state, same as load
|
||||
* @param {RestorePoint} restorePoint
|
||||
* @param {OntimeEvent} timer
|
||||
*/
|
||||
resume(timer: OntimeEvent, restorePoint: RestorePoint) {
|
||||
this._clear();
|
||||
|
||||
// this is pretty much the same as load, with a few exceptions
|
||||
this.loadedTimerId = timer.id;
|
||||
this.loadedTimerStart = timer.timeStart;
|
||||
this.loadedTimerEnd = timer.timeEnd;
|
||||
|
||||
this.timer.duration = calculateDuration(timer.timeStart, timer.timeEnd);
|
||||
this.playback = restorePoint.playback;
|
||||
this.timer.timerType = timer.timerType;
|
||||
this.timer.endAction = timer.endAction;
|
||||
this.timer.startedAt = restorePoint.startedAt;
|
||||
this.timer.addedTime = restorePoint.addedTime;
|
||||
this.pausedTime = 0;
|
||||
this.pausedAt = restorePoint.pausedAt;
|
||||
|
||||
this.timer.current = this.timer.duration;
|
||||
if (this.timer.timerType === TimerType.TimeToEnd) {
|
||||
const now = clock.timeNow();
|
||||
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
|
||||
}
|
||||
|
||||
this._onResume();
|
||||
}
|
||||
|
||||
_onResume() {
|
||||
eventStore.batchSet({
|
||||
playback: this.playback,
|
||||
timer: this.timer,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads information for currently running timer
|
||||
* @param timer
|
||||
@@ -171,10 +121,17 @@ export class TimerService {
|
||||
|
||||
/**
|
||||
* Loads given timer to object
|
||||
* @param {OntimeEvent} timer
|
||||
* @param {initialLoadingData} initialData
|
||||
* @param {object} timer
|
||||
* @param initialData
|
||||
* @param {number} timer.id
|
||||
* @param {number} timer.timeStart
|
||||
* @param {number} timer.timeEnd
|
||||
* @param {number} timer.duration
|
||||
* @param {string} timer.timerBehaviour
|
||||
* @param {string} timer.timerType
|
||||
* @param {boolean} timer.skip
|
||||
*/
|
||||
load(timer: OntimeEvent, initialData?: initialLoadingData) {
|
||||
load(timer, initialData?: initialLoadingData) {
|
||||
if (timer.skip) {
|
||||
throw new Error('Refuse load of skipped event');
|
||||
}
|
||||
@@ -198,7 +155,7 @@ export class TimerService {
|
||||
this.timer.current = getCurrent(now, this.timer.duration, 0, 0, now, timer.timeEnd, this.timer.timerType);
|
||||
}
|
||||
|
||||
if (initialData) {
|
||||
if (typeof initialData !== 'undefined') {
|
||||
this.timer = { ...this.timer, ...initialData };
|
||||
}
|
||||
|
||||
@@ -215,13 +172,12 @@ export class TimerService {
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onLoad);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this.loadedTimerId) {
|
||||
if (this.playback === Playback.Roll) {
|
||||
logger.error(LogOrigin.Playback, 'Cannot start while waiting for event');
|
||||
logger.error('PLAYBACK', 'Cannot start while waiting for event');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -266,7 +222,6 @@ export class TimerService {
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onStart);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
pause() {
|
||||
@@ -282,7 +237,6 @@ export class TimerService {
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onPause);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
stop() {
|
||||
@@ -300,14 +254,13 @@ export class TimerService {
|
||||
timer: this.timer,
|
||||
});
|
||||
integrationService.dispatch(TimerLifeCycle.onStop);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds time to running timer by given amount
|
||||
* Delays running timer by given amount
|
||||
* @param {number} amount
|
||||
*/
|
||||
addTime(amount: number) {
|
||||
delay(amount: number) {
|
||||
if (!this.loadedTimerId) {
|
||||
return;
|
||||
}
|
||||
@@ -327,7 +280,6 @@ export class TimerService {
|
||||
|
||||
// force an update
|
||||
this.update(true);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
private updateRoll() {
|
||||
@@ -442,7 +394,6 @@ export class TimerService {
|
||||
PlaybackService.startNext();
|
||||
}
|
||||
}
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -484,19 +435,6 @@ export class TimerService {
|
||||
|
||||
_onRoll() {
|
||||
eventStore.set('playback', this.playback);
|
||||
this._saveState();
|
||||
}
|
||||
|
||||
async _saveState() {
|
||||
if (this.saveRestorePoint) {
|
||||
await this.saveRestorePoint({
|
||||
playback: this.playback,
|
||||
selectedEventId: this.loadedTimerId,
|
||||
startedAt: this.timer.startedAt,
|
||||
addedTime: this.timer.addedTime,
|
||||
pausedAt: this.pausedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Playback } from 'ontime-types';
|
||||
|
||||
import { isRestorePoint, RestorePoint, RestoreService } from '../RestoreService.js';
|
||||
|
||||
describe('isRestorePoint()', () => {
|
||||
it('validates a well defined object', () => {
|
||||
let restorePoint = {
|
||||
playback: 'play',
|
||||
selectedEventId: '123',
|
||||
startedAt: 1,
|
||||
addedTime: 2,
|
||||
pausedAt: 3,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
|
||||
restorePoint = {
|
||||
playback: 'roll',
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
});
|
||||
|
||||
describe('rejects a badly formatted file', () => {
|
||||
it('with invalid playback value', () => {
|
||||
const restorePoint = {
|
||||
playback: 'unknown',
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with missing playback value', () => {
|
||||
const restorePoint = {
|
||||
selectedEventId: '123',
|
||||
startedAt: null,
|
||||
addedTime: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
it('with incorrect value', () => {
|
||||
const restorePoint = {
|
||||
playback: 'roll',
|
||||
selectedEventId: '123',
|
||||
startedAt: 'testing',
|
||||
addedTime: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
expect(isRestorePoint(restorePoint)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RestoreService()', () => {
|
||||
describe('load()', () => {
|
||||
it('loads working file with times', () => {
|
||||
const expected = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 5678,
|
||||
pausedAt: 9087,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
|
||||
|
||||
const testLoad = restoreService.load();
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('loads working file without times', () => {
|
||||
const expected = {
|
||||
playback: Playback.Stop,
|
||||
selectedEventId: null,
|
||||
startedAt: null,
|
||||
addedTime: null,
|
||||
pausedAt: null,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
|
||||
|
||||
const testLoad = restoreService.load();
|
||||
expect(testLoad).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('does not load wrong play state', () => {
|
||||
const expected = {
|
||||
playback: 'does-not-exist',
|
||||
selectedEventId: 'da5b4',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
vi.spyOn<any, any>(restoreService, 'read').mockImplementation(() => JSON.stringify(expected));
|
||||
|
||||
const testLoad = restoreService.load();
|
||||
expect(testLoad).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save()', () => {
|
||||
it('saves data to file', async () => {
|
||||
const testData: RestorePoint = {
|
||||
playback: Playback.Play,
|
||||
selectedEventId: '1234',
|
||||
startedAt: 1234,
|
||||
addedTime: 1234,
|
||||
pausedAt: 1234,
|
||||
};
|
||||
|
||||
const restoreService = new RestoreService('/path/to/restore/file');
|
||||
const writeSpy = vi.spyOn<any, any>(restoreService, 'write').mockImplementation(() => undefined);
|
||||
await restoreService.save(testData);
|
||||
expect(writeSpy).toHaveBeenCalledWith(JSON.stringify(testData));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,6 @@ import { clock } from '../Clock.js';
|
||||
*/
|
||||
export function forceReset() {
|
||||
eventLoader.reset();
|
||||
sendRefetch();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
}
|
||||
|
||||
@@ -192,15 +191,11 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
|
||||
// modify rundown
|
||||
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([id]);
|
||||
notifyChanges({ timer: [id], external: true });
|
||||
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
@@ -211,11 +206,7 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
||||
|
||||
const newEvent = await cachedEdit(eventData.id, eventData);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([newEvent.id]);
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
@@ -228,14 +219,9 @@ export async function editEvent(eventData: Partial<OntimeEvent> | Partial<Ontime
|
||||
export async function deleteEvent(eventId) {
|
||||
await cachedDelete(eventId);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer([eventId]);
|
||||
|
||||
notifyChanges({ timer: [eventId], external: true });
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,9 +231,7 @@ export async function deleteEvent(eventId) {
|
||||
export async function deleteAllEvents() {
|
||||
await cachedClear();
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
forceReset();
|
||||
notifyChanges({ timer: true, external: true, reset: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,22 +244,15 @@ export async function deleteAllEvents() {
|
||||
export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
const reorderedItem = await cachedReorder(eventId, from, to);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
return reorderedItem;
|
||||
}
|
||||
|
||||
export async function applyDelay(eventId: string) {
|
||||
await cachedApplyDelay(eventId);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,11 +264,7 @@ export async function applyDelay(eventId: string) {
|
||||
export async function swapEvents(from: string, to: string) {
|
||||
await cachedSwap(from, to);
|
||||
|
||||
// notify timer service of changed events
|
||||
updateTimer();
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,3 +274,26 @@ export async function swapEvents(from: string, to: string) {
|
||||
function updateChangeNumEvents() {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,3 @@ export const resolveStylesDirectory = join(externalsStartDirectory, config.style
|
||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||
|
||||
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
|
||||
|
||||
// path to restore file
|
||||
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { EndAction, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { parseExcel, parseJson, validateEvent } from '../parser.js';
|
||||
@@ -525,7 +525,7 @@ describe('test event validator', () => {
|
||||
expect(typeof validated.timeStart).toEqual('number');
|
||||
expect(validated.timeStart).toEqual(0);
|
||||
expect(typeof validated.timeEnd).toEqual('number');
|
||||
expect(validated.timeEnd).toEqual(0);
|
||||
expect(validated.timeEnd).toEqual(2);
|
||||
});
|
||||
|
||||
it('handles bad objects', () => {
|
||||
@@ -579,24 +579,24 @@ describe('test parseExcel function', () => {
|
||||
[
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Event Title',
|
||||
'Presenter Name',
|
||||
'Event Subtitle',
|
||||
'Title',
|
||||
'Presenter',
|
||||
'Subtitle',
|
||||
'End Action',
|
||||
'Timer type',
|
||||
'Is Public? (x)',
|
||||
'Skip? (x)',
|
||||
'Public',
|
||||
'Skip',
|
||||
'Notes',
|
||||
'User0:test0',
|
||||
'User1:test1',
|
||||
'User2:test2',
|
||||
'User3:test3',
|
||||
'User4:test4',
|
||||
'User5:test5',
|
||||
'User6:test6',
|
||||
'user7:test7',
|
||||
'user8:test8',
|
||||
'user9:test9',
|
||||
'test0',
|
||||
'test1',
|
||||
'test2',
|
||||
'test3',
|
||||
'test4',
|
||||
'test5',
|
||||
'test6',
|
||||
'test7',
|
||||
'test8',
|
||||
'test9',
|
||||
'Colour',
|
||||
'cue',
|
||||
],
|
||||
@@ -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 = {
|
||||
title: 'Test Event',
|
||||
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.rundown).toBeDefined();
|
||||
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
|
||||
@@ -835,7 +848,16 @@ describe('test views import', () => {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
},
|
||||
viewSettings: {},
|
||||
viewSettings: {
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
notAthing: true,
|
||||
},
|
||||
views: {
|
||||
overrideStyles: true,
|
||||
},
|
||||
@@ -849,7 +871,7 @@ describe('test views import', () => {
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
};
|
||||
const parsed = parseViewSettings(testData, false);
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||
});
|
||||
|
||||
@@ -861,16 +883,7 @@ describe('test views import', () => {
|
||||
version: 2,
|
||||
},
|
||||
};
|
||||
const expectedParsedViewSettings = {
|
||||
normalColor: '#ffffffcc',
|
||||
warningColor: '#FFAB33',
|
||||
warningThreshold: 120000,
|
||||
dangerColor: '#ED3333',
|
||||
dangerThreshold: 60000,
|
||||
endMessage: '',
|
||||
overrideStyles: false,
|
||||
};
|
||||
const parsed = parseViewSettings(testData, true);
|
||||
expect(parsed).toStrictEqual(expectedParsedViewSettings);
|
||||
const parsed = parseViewSettings(testData);
|
||||
expect(parsed).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('mergeObject()', () => {
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test.skip('it only merges fields of the first object', () => {
|
||||
test('it only merges fields of the first object', () => {
|
||||
const a = {
|
||||
first: 'yes',
|
||||
second: 'yes',
|
||||
@@ -64,6 +64,35 @@ describe('mergeObject()', () => {
|
||||
third: '',
|
||||
});
|
||||
});
|
||||
test('merges nested objects', () => {
|
||||
// Define a sample object with nested properties
|
||||
const a = {
|
||||
name: 'John',
|
||||
address: {
|
||||
city: 'New York',
|
||||
postalCode: '10001',
|
||||
},
|
||||
};
|
||||
|
||||
// Define a partial object with nested properties for merging
|
||||
const b = {
|
||||
name: 'Doe',
|
||||
address: {
|
||||
city: 'San Francisco',
|
||||
state: 'CA',
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeObject(a, b);
|
||||
|
||||
expect(merged.name).toBe('Doe');
|
||||
expect(merged.address.city).toBe('San Francisco');
|
||||
// @ts-expect-error -- its ok, just checking
|
||||
expect(merged.address.state).toBe('CA');
|
||||
expect(merged.address.postalCode).toBe('10001');
|
||||
expect(merged.address).not.toBe(a.address);
|
||||
expect(merged.address).not.toBe(b.address);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeUndefined()', () => {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* @description Converts a value to a number if possible, throws otherwise
|
||||
* @param {unknown} value - Value to be converted to a string.
|
||||
* @returns {string} - The converted value as a string.
|
||||
* @throws {Error} Throws an error if the value is null or undefined.
|
||||
*/
|
||||
export function coerceString(value: unknown): string {
|
||||
if (value == null) {
|
||||
throw new Error('Invalid value received');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Converts a value to a number if possible, throws otherwise
|
||||
* @param {unknown} value - Value to be converted to a boolean.
|
||||
* @returns {boolean} - The converted value as a boolean.
|
||||
* @throws {Error} Throws an error if the value is null or undefined.
|
||||
*/
|
||||
export function coerceBoolean(value: unknown): boolean {
|
||||
if (value == null) {
|
||||
throw new Error('Invalid value received');
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Converts a value to a number if possible, throws otherwise
|
||||
* @param {unknown} value - Value to be converted to a number.
|
||||
* @returns {number} - The converted value as a number.
|
||||
* @throws {Error} Throws an error if the value is null, undefined or not a valid number.
|
||||
*/
|
||||
export function coerceNumber(value: unknown): number {
|
||||
if (value == null) {
|
||||
throw new Error('Invalid value received');
|
||||
}
|
||||
const parsedValue = Number(value);
|
||||
if (isNaN(parsedValue)) {
|
||||
throw new Error('Invalid value received');
|
||||
}
|
||||
return parsedValue;
|
||||
}
|
||||
+178
-179
@@ -1,18 +1,27 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- not ready to fully type
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
import { generateId, calculateDuration } from 'ontime-utils';
|
||||
import {
|
||||
generateId,
|
||||
isExcelImportMap,
|
||||
type ExcelImportMap,
|
||||
defaultExcelImportMap,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
type ExcelImportOptions,
|
||||
validateTimes,
|
||||
} from 'ontime-utils';
|
||||
import {
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
SupportedEvent,
|
||||
TimerType,
|
||||
ProjectData,
|
||||
UserFields,
|
||||
EndAction,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
|
||||
import fs from 'fs';
|
||||
import xlsx from 'node-xlsx';
|
||||
|
||||
import { event as eventDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
import { deleteFile, makeString } from './parserUtils.js';
|
||||
@@ -33,27 +42,55 @@ export const JSON_MIME = 'application/json';
|
||||
/**
|
||||
* @description Excel array parser
|
||||
* @param {array} excelData - array with excel sheet
|
||||
* @param {ExcelImportOptions} options - an object that contains the import map
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export const parseExcel = async (excelData) => {
|
||||
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
|
||||
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
||||
const projectData: Partial<ProjectData> = {
|
||||
title: '',
|
||||
description: '',
|
||||
publicUrl: '',
|
||||
publicInfo: '',
|
||||
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 = [];
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
|
||||
// title stuff: strings
|
||||
let titleIndex: number | null = null;
|
||||
let cueIndex: number | null = null;
|
||||
let presenterIndex: number | null = null;
|
||||
let subtitleIndex: number | null = null;
|
||||
let isPublicIndex: number | null = null;
|
||||
let skipIndex: number | null = null;
|
||||
let notesIndex: number | null = null;
|
||||
let colourIndex: number | null = null;
|
||||
|
||||
// options: booleans
|
||||
let isPublicIndex: number | null = null;
|
||||
let skipIndex: number | null = null;
|
||||
|
||||
// times: numbers
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
let durationIndex: number | null = null;
|
||||
|
||||
// options: enum properties
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
// user fields: strings
|
||||
let user0Index: number | null = null;
|
||||
let user1Index: number | null = null;
|
||||
let user2Index: number | null = null;
|
||||
@@ -64,13 +101,11 @@ export const parseExcel = async (excelData) => {
|
||||
let user7Index: number | null = null;
|
||||
let user8Index: number | null = null;
|
||||
let user9Index: number | null = null;
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
excelData
|
||||
.filter((e) => e.length > 0)
|
||||
.forEach((row) => {
|
||||
// project data imports are on the column to the right
|
||||
// these fields contain the data to its right
|
||||
let projectTitleNext = false;
|
||||
let projectDescriptionNext = false;
|
||||
let publicUrlNext = false;
|
||||
@@ -81,29 +116,31 @@ export const parseExcel = async (excelData) => {
|
||||
const event: Partial<OntimeEvent> = {};
|
||||
|
||||
row.forEach((column, j) => {
|
||||
// check flags
|
||||
// 1. we check if we have set a flag for a known field
|
||||
if (projectTitleNext) {
|
||||
projectData.title = column;
|
||||
projectData.title = makeString(column, '');
|
||||
projectTitleNext = false;
|
||||
} else if (projectDescriptionNext) {
|
||||
projectData.description = column;
|
||||
projectData.description = makeString(column, '');
|
||||
projectDescriptionNext = false;
|
||||
} else if (publicUrlNext) {
|
||||
projectData.publicUrl = column;
|
||||
projectData.publicUrl = makeString(column, '');
|
||||
publicUrlNext = false;
|
||||
} else if (publicInfoNext) {
|
||||
projectData.publicInfo = column;
|
||||
projectData.publicInfo = makeString(column, '');
|
||||
publicInfoNext = false;
|
||||
} else if (backstageUrlNext) {
|
||||
projectData.backstageUrl = column;
|
||||
projectData.backstageUrl = makeString(column, '');
|
||||
backstageUrlNext = false;
|
||||
} else if (backstageInfoNext) {
|
||||
projectData.backstageInfo = column;
|
||||
projectData.backstageInfo = makeString(column, '');
|
||||
backstageInfoNext = false;
|
||||
} else if (j === timeStartIndex) {
|
||||
event.timeStart = parseExcelDate(column);
|
||||
} else if (j === timeEndIndex) {
|
||||
event.timeEnd = parseExcelDate(column);
|
||||
} else if (j === durationIndex) {
|
||||
event.duration = parseExcelDate(column);
|
||||
} else if (j === titleIndex) {
|
||||
event.title = makeString(column, '');
|
||||
} else if (j === cueIndex) {
|
||||
@@ -119,155 +156,130 @@ export const parseExcel = async (excelData) => {
|
||||
} else if (j === notesIndex) {
|
||||
event.note = makeString(column, '');
|
||||
} else if (j === endActionIndex) {
|
||||
if (column === '') {
|
||||
event.endAction = EndAction.None;
|
||||
} else {
|
||||
event.endAction = column;
|
||||
}
|
||||
event.endAction = validateEndAction(column);
|
||||
} else if (j === timerTypeIndex) {
|
||||
if (column === '') {
|
||||
event.timerType = TimerType.CountDown;
|
||||
} else {
|
||||
event.timerType = column;
|
||||
}
|
||||
event.timerType = validateTimerType(column);
|
||||
} else if (j === colourIndex) {
|
||||
event.colour = column;
|
||||
event.colour = makeString(column, '');
|
||||
} else if (j === user0Index) {
|
||||
event.user0 = column;
|
||||
event.user0 = makeString(column, '');
|
||||
} else if (j === user1Index) {
|
||||
event.user1 = column;
|
||||
event.user1 = makeString(column, '');
|
||||
} else if (j === user2Index) {
|
||||
event.user2 = column;
|
||||
event.user2 = makeString(column, '');
|
||||
} else if (j === user3Index) {
|
||||
event.user3 = column;
|
||||
event.user3 = makeString(column, '');
|
||||
} else if (j === user4Index) {
|
||||
event.user4 = column;
|
||||
event.user4 = makeString(column, '');
|
||||
} else if (j === user5Index) {
|
||||
event.user5 = column;
|
||||
event.user5 = makeString(column, '');
|
||||
} else if (j === user6Index) {
|
||||
event.user6 = column;
|
||||
event.user6 = makeString(column, '');
|
||||
} else if (j === user7Index) {
|
||||
event.user7 = column;
|
||||
event.user7 = makeString(column, '');
|
||||
} else if (j === user8Index) {
|
||||
event.user8 = column;
|
||||
event.user8 = makeString(column, '');
|
||||
} else if (j === user9Index) {
|
||||
event.user9 = column;
|
||||
event.user9 = makeString(column, '');
|
||||
} else {
|
||||
// 2. if there is no flag, lets see if we know the field type
|
||||
if (typeof column === 'string') {
|
||||
const col = column.toLowerCase();
|
||||
|
||||
// look for keywords
|
||||
// need to make sure it is a string first
|
||||
switch (col) {
|
||||
case 'project name':
|
||||
case importMap.projectName:
|
||||
projectTitleNext = true;
|
||||
break;
|
||||
case 'project description':
|
||||
case importMap.projectDescription:
|
||||
projectDescriptionNext = true;
|
||||
break;
|
||||
case 'public url':
|
||||
case importMap.publicUrl:
|
||||
publicUrlNext = true;
|
||||
break;
|
||||
case 'public info':
|
||||
case importMap.publicInfo:
|
||||
publicInfoNext = true;
|
||||
break;
|
||||
case 'backstage url':
|
||||
case importMap.backstageUrl:
|
||||
backstageUrlNext = true;
|
||||
break;
|
||||
case 'backstage info':
|
||||
case importMap.backstageInfo:
|
||||
backstageInfoNext = true;
|
||||
break;
|
||||
case 'time start':
|
||||
case 'start':
|
||||
case importMap.timeStart:
|
||||
timeStartIndex = j;
|
||||
break;
|
||||
case 'time end':
|
||||
case 'end':
|
||||
case 'finish':
|
||||
case importMap.timeEnd:
|
||||
timeEndIndex = j;
|
||||
break;
|
||||
case 'cue':
|
||||
case 'page':
|
||||
case importMap.duration:
|
||||
durationIndex = j;
|
||||
break;
|
||||
case importMap.cue:
|
||||
cueIndex = j;
|
||||
break;
|
||||
case 'event title':
|
||||
case 'title':
|
||||
case importMap.title:
|
||||
titleIndex = j;
|
||||
break;
|
||||
case 'presenter name':
|
||||
case 'speaker':
|
||||
case 'presenter':
|
||||
case importMap.presenter:
|
||||
presenterIndex = j;
|
||||
break;
|
||||
case 'event subtitle':
|
||||
case 'subtitle':
|
||||
case importMap.subtitle:
|
||||
subtitleIndex = j;
|
||||
break;
|
||||
case 'is public? (x)':
|
||||
case 'is public':
|
||||
case 'public':
|
||||
case importMap.isPublic:
|
||||
isPublicIndex = j;
|
||||
break;
|
||||
case 'skip? (x)':
|
||||
case 'skip?':
|
||||
case 'skip':
|
||||
case importMap.skip:
|
||||
skipIndex = j;
|
||||
break;
|
||||
case 'note':
|
||||
case 'notes':
|
||||
case importMap.note:
|
||||
notesIndex = j;
|
||||
break;
|
||||
case 'colour':
|
||||
case 'color':
|
||||
case importMap.colour:
|
||||
colourIndex = j;
|
||||
break;
|
||||
case 'end action':
|
||||
case importMap.endAction:
|
||||
endActionIndex = j;
|
||||
break;
|
||||
case 'timer type':
|
||||
case importMap.timerType:
|
||||
timerTypeIndex = j;
|
||||
break;
|
||||
default:
|
||||
// look for user defined
|
||||
if (col.startsWith('user')) {
|
||||
const index = column.charAt(4);
|
||||
// name is the bit after the :
|
||||
const [, name] = column.split(':');
|
||||
if (typeof name !== 'undefined') {
|
||||
if (index === '0') {
|
||||
customUserFields.user0 = name;
|
||||
user0Index = j;
|
||||
} else if (index === '1') {
|
||||
customUserFields.user1 = name;
|
||||
user1Index = j;
|
||||
} else if (index === '2') {
|
||||
customUserFields.user2 = name;
|
||||
user2Index = j;
|
||||
} else if (index === '3') {
|
||||
customUserFields.user3 = name;
|
||||
user3Index = j;
|
||||
} else if (index === '4') {
|
||||
customUserFields.user4 = name;
|
||||
user4Index = j;
|
||||
} else if (index === '5') {
|
||||
customUserFields.user5 = name;
|
||||
user5Index = j;
|
||||
} else if (index === '6') {
|
||||
customUserFields.user6 = name;
|
||||
user6Index = j;
|
||||
} else if (index === '7') {
|
||||
customUserFields.user7 = name;
|
||||
user7Index = j;
|
||||
} else if (index === '8') {
|
||||
customUserFields.user8 = name;
|
||||
user8Index = j;
|
||||
} else if (index === '9') {
|
||||
customUserFields.user9 = name;
|
||||
user9Index = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
case importMap.user0:
|
||||
user0Index = j;
|
||||
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 any data was found, push to array
|
||||
// take care of it in the next step
|
||||
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
rundown,
|
||||
project: projectData,
|
||||
@@ -286,17 +298,16 @@ export const parseExcel = async (excelData) => {
|
||||
app: 'ontime',
|
||||
version: 2,
|
||||
},
|
||||
userFields: { ...dbModel.userFields, ...customUserFields },
|
||||
userFields: customUserFields,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description JSON parser function for v1 of data system
|
||||
* @param {object} jsonData - json data JSON object to be parsed
|
||||
* @param {boolean} [enforce=false] - flag, tells to create an object anyway
|
||||
* @description JSON parser function for ontime project file
|
||||
* @param {object} jsonData - project file to be parsed
|
||||
* @returns {object} - parsed object
|
||||
*/
|
||||
export const parseJson = async (jsonData, enforce = false): Promise<DatabaseModel | null> => {
|
||||
export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
|
||||
if (!jsonData || typeof jsonData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
@@ -307,17 +318,17 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
|
||||
// parse Events
|
||||
returnData.rundown = parseRundown(jsonData);
|
||||
// parse Event
|
||||
returnData.project = parseProject(jsonData, enforce);
|
||||
returnData.project = parseProject(jsonData) ?? dbModel.project;
|
||||
// Settings handled partially
|
||||
returnData.settings = parseSettings(jsonData, enforce);
|
||||
returnData.settings = parseSettings(jsonData) ?? dbModel.settings;
|
||||
// View settings handled partially
|
||||
returnData.viewSettings = parseViewSettings(jsonData, enforce);
|
||||
returnData.viewSettings = parseViewSettings(jsonData) ?? dbModel.viewSettings;
|
||||
// Import Aliases if any
|
||||
returnData.aliases = parseAliases(jsonData);
|
||||
// Import user fields if any
|
||||
returnData.userFields = parseUserFields(jsonData);
|
||||
// Import OSC settings if any
|
||||
returnData.osc = parseOsc(jsonData, enforce);
|
||||
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
||||
// Import HTTP settings if any
|
||||
// returnData.http = parseHttp(jsonData, enforce);
|
||||
|
||||
@@ -344,19 +355,19 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
|
||||
const e = eventArgs;
|
||||
const d = eventDef;
|
||||
const start = e.timeStart != null && typeof e.timeStart === 'number' ? e.timeStart : d.timeStart;
|
||||
const end = e.timeEnd != null && typeof e.timeEnd === 'number' ? e.timeEnd : d.timeEnd;
|
||||
|
||||
const { timeStart, timeEnd, duration } = validateTimes(e.timeStart, e.timeEnd, e.duration);
|
||||
|
||||
event = {
|
||||
...d,
|
||||
title: makeString(e.title, d.title),
|
||||
subtitle: makeString(e.subtitle, d.subtitle),
|
||||
presenter: makeString(e.presenter, d.presenter),
|
||||
timeStart: start,
|
||||
timeEnd: end,
|
||||
endAction: makeString(e.endAction, d.endAction),
|
||||
timerType: makeString(e.timerType, d.timerType),
|
||||
duration: calculateDuration(start, end),
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
endAction: validateEndAction(e.endAction, EndAction.None),
|
||||
timerType: validateTimerType(e.timerType, TimerType.CountDown),
|
||||
isPublic: typeof e.isPublic === 'boolean' ? e.isPublic : d.isPublic,
|
||||
skip: typeof e.skip === 'boolean' ? e.skip : d.skip,
|
||||
note: makeString(e.note, d.note),
|
||||
@@ -371,8 +382,8 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
user8: makeString(e.user8, d.user8),
|
||||
user9: makeString(e.user9, d.user9),
|
||||
colour: makeString(e.colour, d.colour),
|
||||
id,
|
||||
cue: makeString(e.cue, cueFallback),
|
||||
id,
|
||||
type: 'event',
|
||||
};
|
||||
}
|
||||
@@ -380,68 +391,56 @@ export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: stri
|
||||
return event;
|
||||
};
|
||||
|
||||
type ResponseOK = { data: Partial<DatabaseModel>; message: 'success' };
|
||||
type ResponseError = { error: true; message: string };
|
||||
type ResponseOK = {
|
||||
data: Partial<DatabaseModel>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Middleware function that checks file type and calls relevant parser
|
||||
* @param {string} file - reference to file
|
||||
* @param options - import options
|
||||
* @return {object} - parse result message
|
||||
*/
|
||||
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
|
||||
let res: Partial<ResponseOK | ResponseError> = {};
|
||||
export const fileHandler = async (file: string, options: ExcelImportOptions): Promise<Partial<ResponseOK>> => {
|
||||
const res: Partial<ResponseOK> = {};
|
||||
|
||||
// check which file type are we dealing with
|
||||
if (file.endsWith('.xlsx')) {
|
||||
try {
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === 'ontime' || name.toLowerCase() === 'event schedule');
|
||||
// we need to check that the options are applicable
|
||||
if (!isExcelImportMap(options)) {
|
||||
throw new Error('Got incorrect options to excel import', JSON.parse(options));
|
||||
}
|
||||
|
||||
// we only look at worksheets called ontime or event schedule
|
||||
if (excelData?.data) {
|
||||
const dataFromExcel = await parseExcel(excelData.data);
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
res.data.project = parseProject(dataFromExcel, true);
|
||||
res.data.userFields = parseUserFields(dataFromExcel);
|
||||
res.message = 'success';
|
||||
} else {
|
||||
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
|
||||
res = {
|
||||
error: true,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
res = { error: true, message: `Error parsing file: ${error}` };
|
||||
const excelData = xlsx
|
||||
.parse(file, { cellDates: true })
|
||||
.find(({ name }) => name.toLowerCase() === options.worksheet);
|
||||
|
||||
if (excelData?.data) {
|
||||
const dataFromExcel = parseExcel(excelData.data, options);
|
||||
// we run the parsed data through an extra step to ensure the objects shape
|
||||
res.data = {};
|
||||
res.data.rundown = parseRundown(dataFromExcel);
|
||||
res.data.project = parseProject(dataFromExcel);
|
||||
res.data.userFields = parseUserFields(dataFromExcel);
|
||||
return res;
|
||||
} else {
|
||||
throw new Error(`Could not find data to import, maybe the worksheet name is incorrect: ${options.worksheet}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.endsWith('.json')) {
|
||||
// if json check version
|
||||
const rawdata = fs.readFileSync(file);
|
||||
const rawdata = fs.readFileSync(file).toString();
|
||||
let uploadedJson = null;
|
||||
|
||||
try {
|
||||
uploadedJson = JSON.parse(rawdata);
|
||||
} catch (error) {
|
||||
return { error: true, message: 'Error parsing JSON file' };
|
||||
uploadedJson = JSON.parse(rawdata);
|
||||
if (uploadedJson.settings.version !== 2) {
|
||||
throw new Error(`Project version unknown ${uploadedJson.settings.version}`);
|
||||
}
|
||||
res.data = await parseJson(uploadedJson);
|
||||
|
||||
if (uploadedJson.settings.version === 2) {
|
||||
try {
|
||||
res.data = await parseJson(uploadedJson);
|
||||
res.message = 'success';
|
||||
} catch (error) {
|
||||
res = { error: true, message: `Error parsing file: ${error}` };
|
||||
}
|
||||
} else {
|
||||
res = { error: true, message: 'Error parsing file, version unknown' };
|
||||
}
|
||||
// delete file
|
||||
await deleteFile(file);
|
||||
return res;
|
||||
}
|
||||
|
||||
// delete file
|
||||
await deleteFile(file);
|
||||
return res;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import {
|
||||
Alias,
|
||||
EndAction,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
ProjectData,
|
||||
Settings,
|
||||
TimerLifeCycle,
|
||||
TimerType,
|
||||
UserFields,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
@@ -45,18 +43,6 @@ export const parseRundown = (data): OntimeRundown => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// validate the right endAction is used
|
||||
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
|
||||
e.endAction = EndAction.None;
|
||||
console.log('WARNING: invalid End Action provided, using default');
|
||||
}
|
||||
|
||||
// validate the right timerType is used
|
||||
if (e.timerType && !Object.values(TimerType).includes(e.timerType)) {
|
||||
e.timerType = TimerType.CountDown;
|
||||
console.log('WARNING: invalid Timer Type provided, using default');
|
||||
}
|
||||
|
||||
if (e.type === 'event') {
|
||||
eventIndex += 1;
|
||||
const event = validateEvent(e, eventIndex.toString());
|
||||
@@ -88,10 +74,9 @@ export const parseRundown = (data): OntimeRundown => {
|
||||
/**
|
||||
* Parse event portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseProject = (data, enforce): ProjectData => {
|
||||
export const parseProject = (data): ProjectData => {
|
||||
let newProjectData: Partial<ProjectData> = {};
|
||||
// we are adding this here to aid transition, should be removed once enough time has past that users have fully migrated
|
||||
// TODO: Remove eventually
|
||||
@@ -109,9 +94,6 @@ export const parseProject = (data, enforce): ProjectData => {
|
||||
backstageUrl: project.backstageUrl || dbModel.project.backstageUrl,
|
||||
backstageInfo: project.backstageInfo || dbModel.project.backstageInfo,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newProjectData = { ...dbModel.project };
|
||||
console.log('Created project object in db');
|
||||
}
|
||||
return newProjectData as ProjectData;
|
||||
};
|
||||
@@ -119,10 +101,9 @@ export const parseProject = (data, enforce): ProjectData => {
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseSettings = (data, enforce): Settings => {
|
||||
export const parseSettings = (data): Settings => {
|
||||
let newSettings: Partial<Settings> = {};
|
||||
if ('settings' in data) {
|
||||
console.log('Found settings definition, importing...');
|
||||
@@ -146,9 +127,6 @@ export const parseSettings = (data, enforce): Settings => {
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
} else if (enforce) {
|
||||
newSettings = dbModel.settings;
|
||||
console.log('Created settings object in db');
|
||||
}
|
||||
return newSettings as Settings;
|
||||
};
|
||||
@@ -156,10 +134,9 @@ export const parseSettings = (data, enforce): Settings => {
|
||||
/**
|
||||
* Parse settings portion of an entry
|
||||
* @param {object} data - data object
|
||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||
* @returns {object} - event object data
|
||||
*/
|
||||
export const parseViewSettings = (data, enforce): ViewSettings => {
|
||||
export const parseViewSettings = (data): ViewSettings => {
|
||||
let newViews: Partial<ViewSettings> = {};
|
||||
if ('viewSettings' in data) {
|
||||
console.log('Found view definition, importing...');
|
||||
@@ -175,13 +152,7 @@ export const parseViewSettings = (data, enforce): ViewSettings => {
|
||||
endMessage: v.endMessage ?? dbModel.viewSettings.endMessage,
|
||||
};
|
||||
|
||||
// write to db
|
||||
newViews = {
|
||||
...viewSettings,
|
||||
};
|
||||
} else if (enforce) {
|
||||
newViews = dbModel.viewSettings;
|
||||
console.log('Created viewSettings object in db');
|
||||
newViews = { ...viewSettings };
|
||||
}
|
||||
return newViews as ViewSettings;
|
||||
};
|
||||
@@ -224,16 +195,11 @@ export const validateOscObject = (data: OscSubscription): boolean => {
|
||||
/**
|
||||
* Parse osc portion of an entry
|
||||
*/
|
||||
export const parseOsc = (
|
||||
data: {
|
||||
osc?: Partial<OSCSettings>;
|
||||
},
|
||||
enforce: boolean,
|
||||
): OSCSettings | Record<string, never> => {
|
||||
export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
|
||||
if ('osc' in data) {
|
||||
console.log('Found OSC definition, importing...');
|
||||
|
||||
const loadedConfig = data?.osc || {};
|
||||
const loadedConfig = data.osc || {};
|
||||
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
|
||||
? loadedConfig.subscriptions
|
||||
: dbModel.osc.subscriptions;
|
||||
@@ -246,10 +212,7 @@ export const parseOsc = (
|
||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||
subscriptions: validatedSubscriptions,
|
||||
};
|
||||
} else if (enforce) {
|
||||
console.log('Created OSC object in db');
|
||||
return { ...dbModel.osc };
|
||||
} else return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import { deepmerge } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* @description Ensures variable is string, it skips object types
|
||||
@@ -52,16 +53,30 @@ export const isEmptyObject = (obj: object) => {
|
||||
|
||||
/**
|
||||
* @description Merges two objects, suppressing undefined keys
|
||||
* @param {object} a
|
||||
* @param {object} b
|
||||
* @param {object} a - any object
|
||||
* @param {object} b - a potential partial object of same time as a
|
||||
*/
|
||||
export const mergeObject = (a, b) => {
|
||||
const merged = {};
|
||||
Object.keys({ ...a, ...b }).map((key) => {
|
||||
merged[key] = typeof b[key] === 'undefined' ? a[key] : b[key];
|
||||
});
|
||||
export function mergeObject<T extends Record<string, any>>(a: T, b: Partial<Record<keyof T, any>>): T {
|
||||
const merged = { ...a };
|
||||
|
||||
for (const key in b) {
|
||||
const aValue = a[key];
|
||||
const bValue = b[key];
|
||||
|
||||
// ignore keys that do not exist in original object
|
||||
if (!Object.hasOwn(merged, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) {
|
||||
// @ts-expect-error -- library side, ignore for now
|
||||
merged[key] = deepmerge(aValue, bValue);
|
||||
} else if (bValue !== undefined) {
|
||||
merged[key] = bValue;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Removes undefined
|
||||
|
||||
@@ -89,14 +89,18 @@ export const forgivingStringToMillis = (value: string, fillLeft = true): number
|
||||
* @returns {number} - time in milliseconds
|
||||
|
||||
*/
|
||||
export const parseExcelDate = (excelDate: string): number => {
|
||||
// attempt converting to date object
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
} else if (isTimeString(excelDate)) {
|
||||
return forgivingStringToMillis(excelDate);
|
||||
export const parseExcelDate = (excelDate: unknown): number => {
|
||||
if (excelDate instanceof Date) {
|
||||
return dateToMillis(excelDate);
|
||||
} else if (typeof excelDate === 'string') {
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
} else if (isTimeString(excelDate)) {
|
||||
return forgivingStringToMillis(excelDate);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
||||
+70
-66
@@ -1,75 +1,15 @@
|
||||
{
|
||||
"rundown": [
|
||||
{
|
||||
"type": "block",
|
||||
"id": "18797"
|
||||
},
|
||||
{
|
||||
"title": "title 1",
|
||||
"title": "First test event",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 60000,
|
||||
"timeEnd": 120000,
|
||||
"duration": 60000,
|
||||
"isPublic": false,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"user0": "",
|
||||
"user1": "",
|
||||
"user2": "",
|
||||
"user3": "",
|
||||
"user4": "",
|
||||
"user5": "",
|
||||
"user6": "",
|
||||
"user7": "",
|
||||
"user8": "",
|
||||
"user9": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "aa42f",
|
||||
"cue": "1"
|
||||
},
|
||||
{
|
||||
"title": "title 2",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 120000,
|
||||
"timeEnd": 180000,
|
||||
"duration": 60000,
|
||||
"isPublic": false,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"user0": "",
|
||||
"user1": "",
|
||||
"user2": "",
|
||||
"user3": "",
|
||||
"user4": "",
|
||||
"user5": "",
|
||||
"user6": "",
|
||||
"user7": "",
|
||||
"user8": "",
|
||||
"user9": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "d71bc",
|
||||
"cue": "2"
|
||||
},
|
||||
{
|
||||
"title": "title 3",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 39600000,
|
||||
"timeEnd": 720000,
|
||||
"duration": 47520000,
|
||||
"timeStart": 32400000,
|
||||
"timeEnd": 36000000,
|
||||
"duration": 3600000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
@@ -85,8 +25,72 @@
|
||||
"user9": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "da5b4",
|
||||
"cue": "3"
|
||||
"id": "aa42f"
|
||||
},
|
||||
{
|
||||
"duration": 600000,
|
||||
"type": "delay",
|
||||
"revision": 0,
|
||||
"id": "b1d5a"
|
||||
},
|
||||
{
|
||||
"title": "Second test event",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 36000000,
|
||||
"timeEnd": 39600000,
|
||||
"duration": 3600000,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"user0": "",
|
||||
"user1": "",
|
||||
"user2": "",
|
||||
"user3": "",
|
||||
"user4": "",
|
||||
"user5": "",
|
||||
"user6": "",
|
||||
"user7": "",
|
||||
"user8": "",
|
||||
"user9": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "d71bc"
|
||||
},
|
||||
{
|
||||
"title": "Lunch",
|
||||
"type": "block",
|
||||
"id": "91682"
|
||||
},
|
||||
{
|
||||
"title": "Third test event",
|
||||
"subtitle": "",
|
||||
"presenter": "",
|
||||
"note": "",
|
||||
"endAction": "none",
|
||||
"timerType": "count-down",
|
||||
"timeStart": 39600000,
|
||||
"timeEnd": 720000,
|
||||
"duration": 0,
|
||||
"isPublic": true,
|
||||
"skip": false,
|
||||
"colour": "",
|
||||
"user0": "",
|
||||
"user1": "",
|
||||
"user2": "",
|
||||
"user3": "",
|
||||
"user4": "",
|
||||
"user5": "",
|
||||
"user6": "",
|
||||
"user7": "",
|
||||
"user8": "",
|
||||
"user9": "",
|
||||
"type": "event",
|
||||
"revision": 0,
|
||||
"id": "da5b4"
|
||||
}
|
||||
],
|
||||
"project": {
|
||||
|
||||
@@ -19,8 +19,7 @@ test('cuesheet displays events and exports csv', async ({ page }) => {
|
||||
await page.getByRole('cell', { name: '+10 min' }).click();
|
||||
await page.getByRole('cell', { name: 'Lunch' }).click();
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByTestId('cuesheet').getByText('Export').click();
|
||||
await page.getByText('CSV').click();
|
||||
await page.getByTestId('cuesheet').getByText('CSV').click();
|
||||
|
||||
// From here we test the CSV download feature
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "2.13.1",
|
||||
"version": "2.9.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"lighdev",
|
||||
|
||||
@@ -14,9 +14,3 @@ export function isOntimeDelay(event: MaybeEvent): event is OntimeDelay {
|
||||
export function isOntimeBlock(event: MaybeEvent): event is OntimeBlock {
|
||||
return event?.type === SupportedEvent.Block;
|
||||
}
|
||||
|
||||
type AnyKeys<T> = keyof T;
|
||||
|
||||
export function isKeyOfType<T extends object>(key: PropertyKey, obj: T): key is AnyKeys<T> {
|
||||
return key in obj;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"endOfLine": "lf",
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
|
||||
+18
-1
@@ -1,12 +1,13 @@
|
||||
// runtime utils
|
||||
export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
|
||||
export { validatePlayback } from './src/validate-action/validatePlayback.js';
|
||||
export { validateTimes } from './src/validate-events/validateEvent.js';
|
||||
export { calculateDuration } from './src/validate-events/validateEvent.js';
|
||||
|
||||
// rundown utils
|
||||
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||
export { generateId } from './src/generate-id/generateId.js';
|
||||
export { calculateDuration } from './src/rundown-utils/rundownUtils.js';
|
||||
export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js';
|
||||
|
||||
// format utils
|
||||
@@ -18,5 +19,21 @@ export { millisToString } from './src/date-utils/millisToString.js';
|
||||
// time utils
|
||||
export { dayInMs, mts } from './src/timeConstants.js';
|
||||
|
||||
// helpers from externals
|
||||
export { deepmerge } from './src/externals/deepmerge.js';
|
||||
|
||||
// generic utilities
|
||||
export { isNumeric } from './src/types/types.js';
|
||||
|
||||
// model validation
|
||||
export { validateEndAction, validateTimerType } from './src/validate-events/validateEvent.js';
|
||||
|
||||
// feature business logic
|
||||
|
||||
// feature business logic - excel import
|
||||
export {
|
||||
type ExcelImportMap,
|
||||
type ExcelImportOptions,
|
||||
defaultExcelImportMap,
|
||||
isExcelImportMap,
|
||||
} from './src/feature/excel-import/excelImport.js';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge-ts": "^5.1.0",
|
||||
"luxon": "^3.3.0",
|
||||
"nanoid": "^4.0.1"
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user