Compare commits

..

28 Commits

Author SHA1 Message Date
cv 1b867b0dbc chore: update tests 2023-09-29 22:23:16 +02:00
cv 34b79e7699 chore: update tests 2023-09-29 22:21:15 +02:00
cv 4498a04a34 refactor: align import map to demo 2023-09-29 22:16:39 +02:00
cv d9df866308 refactor: enforce integers in numbers 2023-09-29 22:10:40 +02:00
cv 8146d88765 refactor: force refetch after mutation 2023-09-29 21:56:15 +02:00
cv d5cb2735ec Merge remote-tracking branch 'origin/master' into excel-import 2023-09-29 21:53:13 +02:00
cv 9b12afe2a6 refactor: align import map to demo 2023-09-29 21:40:22 +02:00
cv e10c0e8c97 refactor: typescript improvements 2023-09-29 21:39:50 +02:00
cv 4e9d9fc075 refactor: parse time fields 2023-09-29 21:12:40 +02:00
cv 45ece13d04 refactor: g-sheet time is object 2023-09-29 21:12:27 +02:00
cv e5e5798272 refactor: update type 2023-09-29 21:12:01 +02:00
cv cf7f6bdbf7 feat: resolve times on excel import, refs #508 2023-09-29 20:19:29 +02:00
cv a0d4f40bec refactor: small cleanups and type improvement 2023-09-28 08:15:54 +02:00
cv ba5ef6668d refactor: parse import fields 2023-09-28 08:15:24 +02:00
cv 9fc04f2fd1 chore: create feature endpoints 2023-09-27 15:09:48 +02:00
cv 13d72dd1a4 style: small presentation tweaks 2023-09-27 15:08:14 +02:00
cv 30905757f3 chore: increase size limits on uploads 2023-09-27 14:31:14 +02:00
cv e09a7d99f0 refactor: handle unexpected data types 2023-09-27 12:16:39 +02:00
cv 52ca04e063 chore: create import map utilities 2023-09-27 12:15:50 +02:00
cv b8188c7485 chore: add external deepmerge utility 2023-09-27 12:14:04 +02:00
cv 85172ae8d7 temp: handle inconsistent errors from server 2023-09-27 09:56:24 +02:00
cv cb871a8c26 refactor: simplify handling of notification 2023-09-27 09:55:18 +02:00
cv 88a853c158 refactor: convert to typescript 2023-09-17 20:55:23 +02:00
cv 99cd9bf0b7 refactor: simplify handling axios errors 2023-09-17 20:49:02 +02:00
cv 29fd145bd9 wip: create upload UI components 2023-09-17 20:48:27 +02:00
cv f6440a503f chore: upgrade deps 2023-09-17 13:16:46 +02:00
cv aa0d4103b1 style: tweaks on components 2023-09-16 14:09:49 +02:00
cv 12b051dadb refactor: rename utilities file 2023-09-16 14:09:17 +02:00
107 changed files with 2132 additions and 1818 deletions
+1 -1
View File
@@ -21,6 +21,6 @@
}
],
"rules": {
"no-console": ["warn", { "allow": ["warn", "error"]}]
"no-console": "warn"
}
}
BIN
View File
Binary file not shown.
-10
View File
@@ -1,10 +0,0 @@
build
coverage
dist
node_modules
playwright-report
**/*.toml
**/*.yml
**/*.json
-1
View File
@@ -1,5 +1,4 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
-2
View File
@@ -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
+44 -36
View File
@@ -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.
![App Window](https://github.com/cpvalente/ontime/blob/master/.github/aux-images/app.png)
@@ -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
View File
@@ -1,5 +1,4 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.13.1",
"version": "2.9.0",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
+30 -5
View File
@@ -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',
]);
}
+91 -16
View File
@@ -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,
{
+1 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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) {
+3 -22
View File
@@ -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>
);
}
@@ -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'>
+8
View File
@@ -0,0 +1,8 @@
export const ontimeProgressGray = {
track: {
background: '#f6f6f6', // $gray-500
},
filledTrack: {
background: '#578AF4', // $blue-500
},
};
+17 -5
View File
@@ -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)',
},
};
+8 -1
View File
@@ -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: {
+2 -2
View File
@@ -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",
+3 -4
View File
@@ -1,8 +1,7 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 120
"printWidth": 120,
"tabWidth": 2
}
+6 -7
View File
@@ -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"
}
+4 -11
View File
@@ -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);
+1 -7
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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}`);
}
+100 -45
View File
@@ -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();
},
];
+1 -1
View File
@@ -42,7 +42,7 @@ const parseDb = async (fileToRead, adapterToUse) => {
adapterToUse.data = dbModel;
}
return parseJson(adapterToUse.data, true);
return parseJson(adapterToUse.data);
};
/**
+9
View File
@@ -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);
+8 -45
View File
@@ -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`);
}
}
}
-160
View File
@@ -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);
+15 -77
View File
@@ -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();
}
}
-3
View File
@@ -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);
+44 -31
View File
@@ -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()', () => {
-42
View File
@@ -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
View File
@@ -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;
};
+7 -44
View File
@@ -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 {};
}
};
/**
+23 -8
View File
@@ -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
+11 -7
View File
@@ -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
View File
@@ -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": {
+1 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.13.1",
"version": "2.9.0",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
-6
View File
@@ -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
View File
@@ -1,5 +1,4 @@
{
"endOfLine": "lf",
"trailingComma": "all",
"tabWidth": 2,
"semi": true,
+18 -1
View File
@@ -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';
+1
View File
@@ -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