Compare commits

...

46 Commits

Author SHA1 Message Date
arc-alex a8bc4eb098 bump to version 3.4.1 2024-07-29 12:52:58 +02:00
arc-alex 807698aee1 restore rundownGetAll 2024-07-29 12:51:44 +02:00
Carlos Valente c5e821df35 bump version to 3.4.0 2024-07-16 19:21:56 +02:00
jwetzell 0fd71c9a00 align docker-compose example with docker image layout 2024-07-16 19:20:05 +02:00
jwetzell 53b453abc8 change docker data path from /external to /data 2024-07-16 19:20:05 +02:00
Carlos Valente fe0665506e chore: update typescript 2024-07-16 13:18:20 +02:00
Carlos Valente 9bd600c4b1 chore: add watch mode in dev 2024-07-16 13:18:20 +02:00
jwetzell bbd4254637 Merge pull request #1138 from cpvalente/chore/bump-version
bump version to 3.4.0-beta.3
2024-07-15 08:23:16 -05:00
jwetzell 2603c777ef bump version to 3.4.0-beta.3 2024-07-15 07:23:00 -05:00
jwetzell 8942a4ba4f Merge pull request #1137 from cpvalente/fix/buildx-platforms
drop arm/v6 image
2024-07-15 07:20:32 -05:00
jwetzell f97be749dc drop arm/v6 image 2024-07-15 07:18:06 -05:00
Carlos Valente 5836d5ffc7 bump version to 3.4.0-beta.2 2024-07-15 10:28:58 +02:00
jwetzell 91f21fdeb1 Merge pull request #1134 from cpvalente/fix/docker-building
switch Dockerfile build stage to non-alpine image
2024-07-14 13:18:24 -05:00
jwetzell 844eec132b use npm to install specific pnpm version 2024-07-13 06:36:22 -05:00
jwetzell adc4b3f8bf switch Dockerfile build stage to non-alpine image 2024-07-12 21:12:57 -05:00
Carlos Valente fb09631db9 bump version to 3.4.0-beta 2024-07-11 14:44:02 +02:00
Carlos Valente 19febb98a5 bump version to 3.4.0.beta 2024-07-11 14:33:18 +02:00
Carlos Valente 6bfa11dd8d fix: format clock as timer 2024-07-11 13:31:25 +02:00
Carlos Valente 4758bc84b5 chore: upgrade node dependencies 2024-07-11 13:31:05 +02:00
Carlos Valente 917b182e39 feat: granular endpoints for rundown 2024-07-11 11:03:07 +02:00
Carlos Valente e6c3322321 feat: add list of changes to refetch 2024-07-11 11:03:07 +02:00
Carlos Valente 00d34eec9b refactor: emit load event on reload 2024-07-11 11:02:49 +02:00
Carlos Valente 84792bc00d refactor: initialise data 2024-07-11 11:02:22 +02:00
Carlos Valente dae32e89a3 refactor: patch project 2024-07-11 11:02:22 +02:00
Carlos Valente 367997fd16 refactor: simplify file checks 2024-07-11 11:02:22 +02:00
Carlos Valente 751c3329f0 refactor: load project 2024-07-11 11:02:22 +02:00
Carlos Valente 9c5e403b18 refactor: add directory for crash logs 2024-07-11 11:02:22 +02:00
Carlos Valente c944e77bc1 refactor: last project may not exist 2024-07-11 11:02:22 +02:00
Carlos Valente d94c1d4252 refactor: remove unnecessary check 2024-07-11 11:02:22 +02:00
Carlos Valente 9f395cb16d refactor: safe get info 2024-07-11 11:02:22 +02:00
Carlos Valente 46195fc043 refactor: rename project 2024-07-11 11:02:22 +02:00
Carlos Valente c9ef8d55c1 refactor: duplicate project 2024-07-11 11:02:22 +02:00
Carlos Valente ad0e821cc0 refactor: delete project 2024-07-11 11:02:22 +02:00
Carlos Valente 21454947e0 refactor: create project 2024-07-11 11:02:22 +02:00
Carlos Valente ad69c0ff80 refactor: project service boundaries 2024-07-11 11:02:22 +02:00
Carlos Valente 20d9df2501 refactor: escalate errors from parser 2024-07-11 11:02:22 +02:00
Carlos Valente de9af5aaa2 refactor: minor code cleanups 2024-07-11 11:02:22 +02:00
Carlos Valente 61280b06b7 refactor: simplify logic dynamic menus 2024-07-10 23:01:47 +02:00
Carlos Valente ce4a48cd8d refactor: extract options per view 2024-07-10 23:01:47 +02:00
Carlos Valente 2877475a35 refactor: allow reset selection by refreshing 2024-07-10 23:01:47 +02:00
Carlos Valente 73e23ee323 feat: projected times in countdown 2024-07-10 23:01:47 +02:00
Carlos Valente 938c5fbb4c refactor: offset cannot be null 2024-07-10 23:01:47 +02:00
Carlos Valente e90e94828d style: add npm badge 2024-07-09 09:30:01 +02:00
jwetzell 1f0401632e have electron dev script wait for local server instance 2024-07-03 22:31:20 +02:00
jwetzell 5c3989c9b6 switch workflows to release trigger (#1115)
* switch docker workflow to release trigger

* switch cli publish workflow to release trigger
2024-07-03 22:30:56 +02:00
Carlos Valente 6ad26f06c7 style: rename log feature 2024-07-02 16:56:23 +02:00
115 changed files with 9710 additions and 7053 deletions
+6 -6
View File
@@ -14,12 +14,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -56,12 +56,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -89,12 +89,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
+4 -4
View File
@@ -1,8 +1,8 @@
name: Ontime CLI build
on:
push:
tags: [ "*" ]
release:
types: [published]
workflow_dispatch:
jobs:
@@ -20,13 +20,13 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
+6 -8
View File
@@ -1,8 +1,8 @@
name: Docker Image CI Ontime
on:
push:
tags: [ "*" ]
release:
types: [published]
workflow_dispatch:
jobs:
@@ -14,8 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Docker Login
uses: docker/login-action@v2.1.0
@@ -32,10 +30,10 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
- name: Build and push pre-release
if: github.event.release.prerelease == true
@@ -43,8 +41,8 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
platforms: linux/amd64,linux/arm64,linux/arm/v7
# Push is a shorthand for --output=type=registry
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ github.event.release.tag_name }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
+4 -4
View File
@@ -17,12 +17,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -73,12 +73,12 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.18.2
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
+1 -1
View File
@@ -1 +1 @@
v18.18.2
v20.15.1
+2 -2
View File
@@ -9,8 +9,8 @@ Ontime consists of 3 distinct parts
The steps below will assume you have locally installed the necessary dependencies.
Other dependencies will be installed as part of the setup
- __node__ (~18.18.2)
- __pnpm__ (>=8)
- __node__ (~20)
- __pnpm__ (~9)
- __docker__ (only necessary to run and build docker images)
## LOCAL DEVELOPMENT
+4 -4
View File
@@ -1,19 +1,19 @@
FROM node:18.18-alpine AS builder
FROM node:20-bullseye AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN npm install -g pnpm@9.5.0
COPY . /app
WORKDIR /app
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:18.18-alpine
FROM node:20-alpine
# Set environment variables
# Environment Variable to signal that we are running production
ENV NODE_ENV=docker
# Ontime Data path
ENV ONTIME_DATA=/external/
ENV ONTIME_DATA=/data/
WORKDIR /app/
+1
View File
@@ -1,6 +1,7 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-green.svg)](https://www.gnu.org/licenses/gpl-3.0)
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.3.3",
"version": "3.4.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.3.3",
"version": "3.4.1",
"private": true,
"type": "module",
"dependencies": {
@@ -70,8 +70,8 @@
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
@@ -86,7 +86,7 @@
"ontime-utils": "workspace:*",
"prettier": "^3.3.1",
"sass": "^1.57.1",
"typescript": "^5.4.3",
"typescript": "^5.5.3",
"vite": "^5.2.11",
"vite-plugin-compression2": "^0.12.0",
"vite-plugin-svgr": "^4.2.0",
+1 -1
View File
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
const url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, {
filename: newFilename,
newFilename,
});
return res.data;
}
@@ -27,6 +27,10 @@ export default function ParamInput(props: EditFormInputProps) {
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'persist') {
return null;
}
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || defaultValue;
@@ -13,14 +13,24 @@
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem
gap: 0.25rem;
}
.columnSection {
.section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
@@ -13,7 +13,7 @@ import {
} from '@chakra-ui/react';
import ParamInput from './ParamInput';
import { ParamField } from './types';
import { isSection, ViewOption } from './types';
import style from './ViewParamsEditor.module.scss';
@@ -22,25 +22,36 @@ type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
/**
* Makes a new URLSearchParams object from the given params object
*/
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.reduce<Record<string, string>>((acc, { id, defaultValue }) => {
acc[id] = String(defaultValue);
return acc;
}, {});
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOption[]) => {
const newSearchParams = new URLSearchParams();
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
// compare which values are different from the default values
Object.entries(paramsObj).forEach(([id, value]) => {
if (typeof value === 'string' && value.length && defaultValues[id] !== value) {
newSearchParams.set(id, value);
}
return newSearchParams;
}, new URLSearchParams());
});
return newSearchParams;
};
interface EditFormDrawerProps {
paramFields: ParamField[];
viewOptions: ViewOption[];
}
export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onClose, onOpen } = useDisclosure();
@@ -68,7 +79,7 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
setSearchParams(newSearchParams);
onClose();
@@ -85,15 +96,29 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
<DrawerBody>
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{paramFields.map((field) => (
<div key={field.title} className={style.columnSection}>
<label className={style.label}>
<span className={style.title}>{field.title}</span>
<span className={style.description}>{field.description}</span>
<ParamInput key={field.title} paramField={field} />
</label>
</div>
))}
{viewOptions.map((option) => {
if (isSection(option)) {
return (
<div key={option.section} className={style.section}>
{option.section}
</div>
);
}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
</form>
</DrawerBody>
@@ -1,15 +1,18 @@
import { CustomFields } from 'ontime-types';
import { type ParamField } from './types';
import type { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
}, additionalOptions ?? {});
return customFieldOptions;
export const makeOptionsFromCustomFields = (
customFields: CustomFields,
additionalOptions: Record<string, string> = {},
) => {
return Object.entries(customFields).reduce((options, [key, value]) => {
options[`custom-${key}`] = `Custom: ${value.label}`;
return options;
}, additionalOptions);
};
const getTimeOption = (timeFormat: string): ParamField => {
export const getTimeOption = (timeFormat: string): ParamField => {
const placeholder = `${timeFormat} (default)`;
return {
id: 'timeformat',
@@ -20,7 +23,7 @@ const getTimeOption = (timeFormat: string): ParamField => {
};
};
const hideTimerSeconds: ParamField = {
export const hideTimerSeconds: ParamField = {
id: 'hideTimerSeconds',
title: 'Hide seconds in timer',
description: 'Whether to hide seconds in the running timer',
@@ -28,484 +31,10 @@ const hideTimerSeconds: ParamField = {
defaultValue: false,
};
const showLeadingZeros: ParamField = {
export const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
showLeadingZeros,
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
hideTimerSeconds,
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const getLowerThirdOptions = (customFields: CustomFields): ParamField[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
];
};
export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
hideTimerSeconds,
];
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {});
return [
getTimeOption(timeFormat),
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
export const getCountdownOptions = (timeFormat: string): ParamField[] => [getTimeOption(timeFormat), hideTimerSeconds];
@@ -1,3 +1,7 @@
type ParamSection = {
section: string;
};
type BaseField = {
id: string;
title: string;
@@ -19,5 +23,15 @@ type MultiOptionsField = {
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
export type ParamField = BaseField &
(StringField | BooleanField | NumberField | OptionsField | MultiOptionsField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
@@ -19,7 +19,7 @@ export default function useLongPress(callback: () => void, options: LongPressOpt
const { threshold = 400, onStart, onFinish, onCancel } = options;
const isLongPressActive = useRef(false);
const isPressed = useRef(false);
const timerId = useRef<NodeJS.Timer>();
const timerId = useRef<NodeJS.Timeout>();
return useMemo(() => {
const start = (event: SyntheticEvent) => {
@@ -17,7 +17,7 @@ export default function LogExport() {
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
Network log
Event log
<Button
variant='ontime-subtle'
size='sm'
@@ -57,7 +57,7 @@ export const settingPanels: Readonly<SettingsOption[]> = [
secondary: [
{
id: 'network__log',
label: 'Application log',
label: 'Event log',
},
{
id: 'network__clients',
@@ -4,7 +4,6 @@ import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty';
import { getOperatorOptions } from '../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
@@ -22,6 +21,7 @@ import FollowButton from './follow-button/FollowButton';
import OperatorBlock from './operator-block/OperatorBlock';
import OperatorEvent from './operator-event/OperatorEvent';
import StatusBar from './status-bar/StatusBar';
import { getOperatorOptions } from './operator.options';
import style from './Operator.module.scss';
@@ -134,7 +134,7 @@ export default function Operator() {
return (
<div className={style.operatorContainer}>
<ViewParamsEditor paramFields={operatorOptions} />
<ViewParamsEditor viewOptions={operatorOptions} />
{editEvent && <EditModal event={editEvent} onClose={() => setEditEvent(null)} />}
<StatusBar
@@ -0,0 +1,60 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.entries(customFields).reduce<
Record<string, { value: string; label: string; colour: string }>
>((acc, [key, field]) => {
acc[key] = { value: key, label: field.label, colour: field.colour };
return acc;
}, {});
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{ section: 'Element visibility' },
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -5,6 +5,7 @@ import {
Message,
OntimeEvent,
ProjectData,
Runtime,
Settings,
SupportedEvent,
TimerMessage,
@@ -36,6 +37,7 @@ type WithDataProps = {
publicEventNext: OntimeEvent | null;
publicEventNow: OntimeEvent | null;
publicSelectedId: string | null;
runtime: Runtime;
selectedId: string | null;
settings: Settings | undefined;
time: ViewExtendedTimer;
@@ -66,7 +68,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
}, [rundownData]);
// websocket data
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow } =
const { clock, timer, message, onAir, eventNext, publicEventNext, publicEventNow, eventNow, runtime } =
useStore(runtimeStore);
const publicSelectedId = publicEventNow?.id ?? null;
const selectedId = eventNow?.id ?? null;
@@ -108,6 +110,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
publicEventNext={publicEventNext}
publicEventNow={publicEventNow}
publicSelectedId={publicSelectedId}
runtime={runtime}
selectedId={selectedId}
settings={settings}
time={TimeManagerType}
@@ -11,7 +11,6 @@ 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 { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -23,6 +22,8 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { getBackstageOptions } from './backstage.options';
import './Backstage.scss';
export const MotionTitleCard = motion(TitleCard);
@@ -99,7 +100,7 @@ export default function Backstage(props: BackstageProps) {
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<ViewParamsEditor paramFields={backstageOptions} />
<ViewParamsEditor viewOptions={backstageOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getClockOptions } from './clock.options';
import './Clock.scss';
interface ClockProps {
@@ -130,7 +131,7 @@ export default function Clock(props: ClockProps) {
}}
data-testid='clock-view'
>
<ViewParamsEditor paramFields={clockOptions} />
<ViewParamsEditor viewOptions={clockOptions} />
<SuperscriptTime
time={clock}
className='clock'
@@ -0,0 +1,76 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -3,6 +3,7 @@ import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString, removeLeadingZero
import type { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { timerPlaceholder, timerPlaceholderMin } from '../../../common/utils/styleUtils';
import { formatTime } from '../../../common/utils/time';
type TimerTypeParams = Pick<ViewExtendedTimer, 'timerType' | 'current' | 'elapsed' | 'clock'>;
@@ -68,11 +69,13 @@ export function getFormattedTimer(
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
}
let timeToParse = timer;
if (timerType === TimerType.Clock) {
return formatTime(timer);
}
let timeToParse = timer;
if (options.removeSeconds) {
const timerIsTimeOfDay = timerType === TimerType.Clock;
const isNegative = timeToParse < -MILLIS_PER_SECOND && !timerIsTimeOfDay && timerType !== TimerType.CountUp;
const isNegative = timeToParse < -MILLIS_PER_SECOND && timerType !== TimerType.CountUp;
if (isNegative) {
// in negative numbers, we need to round down
timeToParse -= MILLIS_PER_MINUTE;
@@ -21,11 +21,11 @@
flex-direction: column;
&__title {
font-size: clamp(24px, 2vw, 32px);
font-size: clamp(1.5rem, 2vw, 2rem);
}
&__events {
font-size: clamp(16px, 1.5vw, 24px);
font-size: clamp(1rem, 1.5vw, 1.5rem);
margin-top: 1em;
overflow-y: auto;
height: 70vh;
@@ -38,18 +38,18 @@
.countdown-container {
height: 100%;
width: 100%;
gap: min(2vh, 16px);
padding: min(2vh, 16px) clamp(16px, 10vw, 64px);
gap: min(2vh, 1rem);
padding: min(2vh, 1rem) clamp(1rem, 10vw, 4rem);
display: grid;
grid-template-rows: auto auto auto auto 1fr;
grid-template-columns: 100%;
grid-template-areas:
'header'
'status'
'clock'
'title'
'timers';
'header'
'status'
'clock'
'title'
'timers';
/* =================== HEADER + EXTRAS ===================*/
@@ -59,13 +59,13 @@
font-weight: 600;
.label {
font-size: clamp(16px, 1.5vw, 24px);
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
@@ -75,7 +75,7 @@
.status {
grid-area: status;
color: var(--label-color-override, $viewer-label-color);
font-size: clamp(32px, 3.5vw, 50px);
font-size: clamp(2rem, 3.5vw, 3.5rem);
font-weight: 600;
}
@@ -105,7 +105,7 @@
.title {
grid-area: title;
background-color: var(--card-background-color-override, $viewer-card-bg-color);
padding: 16px 24px;
padding: 1rem 1.5rem;
border-radius: 8px;
font-weight: 600;
@@ -119,28 +119,49 @@
.timer-group {
grid-area: timers;
display: flex;
justify-content: space-evenly;
align-items: flex-end;
display: grid;
grid-template-areas:
'projected-start projected-end'
'scheduled-start scheduled-end';
grid-template-columns: 1fr 1fr;
justify-items: center;
text-align: center;
row-gap: clamp(1rem, 5vh, 4rem);
align-self: flex-end;
height: fit-content;
.aux-timers {
text-align: center;
font-size: clamp(24px, 1.75vw, 32px);
&__projected-start {
grid-area: projected-start;
}
&__label {
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__projected-end {
grid-area: projected-end;
}
&__value {
font-size: clamp(32px, 3.5vw, 50px);
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&__scheduled-start {
grid-area: scheduled-start;
}
&--delayed {
color: $delay-color;
}
&__scheduled-end {
grid-area: scheduled-end;
}
&__label {
font-size: $timer-label-size;
color: var(--label-color-override, $viewer-label-color);
text-transform: uppercase;
}
&__value {
margin-top: 0.5rem;
font-size: $timer-value-size;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
&--delayed {
color: $delay-color;
}
}
}
@@ -1,9 +1,17 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import {
OntimeEvent,
OntimeRundownEntry,
Playback,
Runtime,
Settings,
SupportedEvent,
TimerPhase,
ViewSettings,
} from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { getCountdownOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -13,7 +21,8 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, isStringBoolean } from '../common/viewUtils';
import { fetchTimerData, TimerMessage } from './countdown.helpers';
import { fetchTimerData, getTimerItems, TimerMessage } from './countdown.helpers';
import { getCountdownOptions } from './countdown.options';
import CountdownSelect from './CountdownSelect';
import './Countdown.scss';
@@ -21,27 +30,26 @@ import './Countdown.scss';
interface CountdownProps {
isMirrored: boolean;
backstageEvents: OntimeEvent[];
time: ViewExtendedTimer;
runtime: Runtime;
selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props;
const { isMirrored, backstageEvents, runtime, selectedId, settings, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation();
const [follow, setFollow] = useState<OntimeEvent | null>(null);
const [runningTimer, setRunningTimer] = useState(0);
const [runningMessage, setRunningMessage] = useState<TimerMessage>(TimerMessage.unhandled);
const [delay, setDelay] = useState(0);
useWindowTitle('Countdown');
// eg. http://localhost:4001/countdown?eventId=ei0us
// Check for user options
// update data to the event we are following
useEffect(() => {
if (!backstageEvents) {
return;
@@ -50,6 +58,12 @@ export default function Countdown(props: CountdownProps) {
const eventId = searchParams.get('eventid');
const eventIndex = searchParams.get('event');
// if there is no event selected, we reset the data
if (!eventId && !eventIndex) {
setFollow(null);
return;
}
let followThis: OntimeEvent | null = null;
const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
@@ -66,29 +80,25 @@ export default function Countdown(props: CountdownProps) {
}
}, [backstageEvents, searchParams]);
useEffect(() => {
if (!follow) {
return;
}
const { message, timer } = fetchTimerData(time, follow, selectedId);
setRunningMessage(message);
setRunningTimer(timer);
}, [follow, selectedId, time]);
// defer rendering until we load stylesheets
if (!shouldRender) {
return null;
}
const { message: runningMessage, timer: runningTimer } = fetchTimerData(time, follow, selectedId, runtime.offset);
const standby = time.playback !== Playback.Play && time.playback !== Playback.Roll && selectedId === follow?.id;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const finished = time.phase === TimerPhase.Overtime;
const isRunningFinished = finished && runningMessage === TimerMessage.running;
const delayedTimerStyles = delay > 0 ? 'aux-timers__value--delayed' : '';
const clock = formatTime(time.clock);
const startTime = follow === null ? '...' : formatTime(follow.timeStart + delay);
const endTime = follow === null ? '...' : formatTime(follow.timeEnd + delay);
const { scheduledStart, scheduledEnd, projectedStart, projectedEnd } = getTimerItems(
follow?.timeStart,
follow?.timeEnd,
delay,
runtime.offset,
);
const hideSeconds = searchParams.get('hideTimerSeconds');
const formattedTimer = getFormattedTimer(runningTimer, time.timerType, getLocalizedString('common.minutes'), {
@@ -96,12 +106,24 @@ export default function Countdown(props: CountdownProps) {
removeLeadingZero: false,
});
const persistParam = () => {
const eventId = searchParams.get('eventid');
if (eventId !== null) {
return { id: 'eventid', value: eventId };
}
const eventIndex = searchParams.get('event');
if (eventIndex !== null) {
return { id: 'eventindex', value: eventIndex };
}
return undefined;
};
const defaultFormat = getDefaultFormat(settings?.timeFormat);
const timeOption = getCountdownOptions(defaultFormat);
const viewOptions = getCountdownOptions(defaultFormat, persistParam());
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<ViewParamsEditor paramFields={timeOption} />
<ViewParamsEditor viewOptions={viewOptions} />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
@@ -122,13 +144,25 @@ export default function Countdown(props: CountdownProps) {
{follow?.title && <div className='title'>{follow.title}</div>}
<div className='timer-group'>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.start_time')}</div>
<SuperscriptTime time={startTime} className={`aux-timers__value ${delayedTimerStyles}`} />
{projectedStart && projectedEnd && (
<div className='timer-group__projected-start'>
<div className='timer-group__label'>{getLocalizedString('common.projected_start')}</div>
<SuperscriptTime time={projectedStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
{projectedStart && projectedEnd && (
<div className='timer-group__projected-end'>
<div className='timer-group__label'>{getLocalizedString('common.projected_end')}</div>
<SuperscriptTime time={projectedEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
)}
<div className='timer-group__scheduled-start'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_start')}</div>
<SuperscriptTime time={scheduledStart} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
<div className='aux-timers'>
<div className='aux-timers__label'>{getLocalizedString('common.end_time')}</div>
<SuperscriptTime time={endTime} className={`aux-timers__value ${delayedTimerStyles}`} />
<div className='timer-group__scheduled-end'>
<div className='timer-group__label'>{getLocalizedString('common.scheduled_end')}</div>
<SuperscriptTime time={scheduledEnd} className={`timer-group__value ${delayedTimerStyles}`} />
</div>
</div>
</div>
@@ -1,6 +1,8 @@
import { OntimeEvent, Playback } from 'ontime-types';
import { ViewExtendedTimer } from '../../../common/models/TimeManager.type';
import { formatTime } from '../../../common/utils/time';
import { isStringBoolean } from '../common/viewUtils';
export enum TimerMessage {
toStart = 'to_start',
@@ -20,48 +22,85 @@ export const sanitiseTitle = (title: string | null) => (title ? title : '{no tit
*/
export const fetchTimerData = (
time: ViewExtendedTimer,
follow: OntimeEvent,
follow: OntimeEvent | null,
selectedId: string | null,
offset: number,
): { message: TimerMessage; timer: number } => {
let message;
let timer;
if (follow === null) {
return { message: TimerMessage.unhandled, timer: 0 };
}
if (selectedId === follow.id) {
// check that is not running
message = time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running;
timer = time.current ?? 0;
} else if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// running timer timer is not the one we are following
if (follow.timeStart > follow.timeEnd) {
// ends day after
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
message = TimerMessage.toStart;
timer = follow.timeStart - time.clock;
} else if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
message = TimerMessage.waiting;
timer = time.current ?? 0;
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
} else {
// if it has ended, we show how long ago
message = TimerMessage.ended;
timer = follow.timeEnd;
}
// if it is selected, it may not be running
return {
message: time.playback === Playback.Pause ? TimerMessage.waiting : TimerMessage.running,
timer: time.current ?? 0,
};
}
return { message, timer };
const showProjected = getShouldShowProjected();
const addedTime = showProjected ? offset : 0;
if (time.clock < follow.timeStart) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock && time.clock <= follow.timeEnd) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// running timer timer is not the one we are following
// ends day after
if (follow.timeStart > follow.timeEnd) {
if (follow.timeStart > time.clock) {
// if it hasnt started, we count to start
return { message: TimerMessage.toStart, timer: follow.timeStart - time.clock - addedTime };
}
if (follow.timeStart <= time.clock) {
// if it has started, we show running timer
return { message: TimerMessage.waiting, timer: time.current ?? 0 };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
}
// if it has ended, we show how long ago
return { message: TimerMessage.ended, timer: follow.timeEnd };
};
/**
* Gets values for the timer items
*/
export function getTimerItems(start: number | undefined, end: number | undefined, delay: number, offset: number) {
if (start == null || end == null) {
return {
scheduledStart: '',
scheduledEnd: '',
projectedStart: '',
projectedEnd: '',
};
}
const showProjected = getShouldShowProjected();
const scheduledStart = formatTime(start + delay);
const scheduledEnd = formatTime(end + delay);
const projectedStart = showProjected ? formatTime(start + delay - offset) : '';
const projectedEnd = showProjected ? formatTime(end + delay - offset) : '';
return {
scheduledStart,
scheduledEnd,
projectedStart,
projectedEnd,
};
}
/**
* Gets from the URL whether the showProjected option is active
*/
function getShouldShowProjected() {
const params = new URL(document.location.href).searchParams;
return isStringBoolean(params.get('showProjected'));
}
@@ -0,0 +1,29 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
const makePersistedField = (id: string, value: string): ViewOption => {
return {
id,
title: 'Used to keep the selection on submit',
description: 'Used to keep the selection on submit',
type: 'persist',
value,
};
};
type Persisted = { id: string; value: string };
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'View behaviour' },
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
];
@@ -3,12 +3,13 @@ import { useSearchParams } from 'react-router-dom';
import { CustomFields, OntimeEvent, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { getLowerThirdOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
import { getPropertyValue } from '../common/viewUtils';
import { getLowerThirdOptions } from './lowerThird.options';
import './LowerThird.scss';
type LowerOptions = {
@@ -182,7 +183,7 @@ export default function LowerThird(props: LowerProps) {
return (
<div className='lower-third' style={{ backgroundColor: `#${options.key}` }}>
<ViewParamsEditor paramFields={getLowerThirdOptions(customFields)} />
<ViewParamsEditor viewOptions={getLowerThirdOptions(customFields)} />
<div
className={`container container--${playState}`}
style={{ minWidth: `${options.width}vw`, animationDuration: transition }}
@@ -0,0 +1,132 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
const topSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
});
const bottomSourceOptions = makeOptionsFromCustomFields(customFields, {
title: 'Title',
none: 'None',
});
return [
{ section: 'View behaviour' },
{
id: 'trigger',
title: 'Animation Trigger',
description: '',
type: 'option',
values: {
event: 'Event Load',
manual: 'Manual',
},
defaultValue: 'manual',
},
{ section: 'Data sources' },
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
{ section: 'View style override' },
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '0000ff (default)',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background',
prefix: '#',
type: 'string',
placeholder: 'ffffffff (default)',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line',
prefix: '#',
type: 'string',
placeholder: 'ff0000ff (default)',
},
];
};
@@ -2,7 +2,6 @@ import { useSearchParams } from 'react-router-dom';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -11,6 +10,8 @@ import { OverridableOptions } from '../../../common/models/View.types';
import { useTranslation } from '../../../translation/TranslationProvider';
import { getFormattedTimer, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { MINIMAL_TIMER_OPTIONS } from './minimalTimer.options';
import './MinimalTimer.scss';
interface MinimalTimerProps {
@@ -161,7 +162,7 @@ export default function MinimalTimer(props: MinimalTimerProps) {
}}
data-testid='minimal-timer'
>
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
<ViewParamsEditor viewOptions={MINIMAL_TIMER_OPTIONS} />
{showEndMessage ? (
<div className='end-message'>{viewSettings.endMessage}</div>
) : (
@@ -0,0 +1,91 @@
import { hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
@@ -43,7 +43,7 @@
.time {
font-size: clamp(32px, 3.5vw, 50px);
font-weight: 600;
color: var(--secondary-color-override, $viewer-secondary-color);
letter-spacing: 0.05em;
line-height: 0.95em;
}
@@ -8,7 +8,6 @@ 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 { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -19,6 +18,8 @@ import { titleVariants } from '../common/animation';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { getPublicOptions } from './public.options';
import './Public.scss';
export const MotionTitleCard = motion(TitleCard);
@@ -73,7 +74,7 @@ export default function Public(props: BackstageProps) {
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<ViewParamsEditor paramFields={publicOptions} />
<ViewParamsEditor viewOptions={publicOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -0,0 +1,44 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
];
};
@@ -4,7 +4,6 @@ import { isOntimeEvent, Playback } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
@@ -14,6 +13,7 @@ import { formatTime, getDefaultFormat } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { isStringBoolean } from '../common/viewUtils';
import { getStudioClockOptions } from './studioClock.options';
import { secondsInMillis, trimRundown } from './studioClock.utils';
import './StudioClock.scss';
@@ -75,7 +75,7 @@ export default function StudioClock(props: StudioClockProps) {
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<ViewParamsEditor paramFields={studioClockOptions} />
<ViewParamsEditor viewOptions={studioClockOptions} />
<div className='clock-container'>
{hasAmPm && <div className='clock__ampm'>{hasAmPm}</div>}
<div className={`studio-timer ${!hideSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
@@ -0,0 +1,9 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../../common/components/view-params-editor/types';
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
];
@@ -15,7 +15,6 @@ import {
import { overrideStylesURL } from '../../../common/api/constants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { useWindowTitle } from '../../../common/hooks/useWindowTitle';
@@ -25,6 +24,8 @@ import { useTranslation } from '../../../translation/TranslationProvider';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getFormattedTimer, getPropertyValue, getTimerByType, isStringBoolean } from '../common/viewUtils';
import { getTimerOptions } from './timer.options';
import './Timer.scss';
// motion
@@ -158,7 +159,7 @@ export default function Timer(props: TimerProps) {
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<ViewParamsEditor paramFields={timerOptions} />
<ViewParamsEditor viewOptions={timerOptions} />
<div className={showBlackout ? 'blackout blackout--active' : 'blackout'} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
@@ -0,0 +1,74 @@
import { CustomFields } from 'ontime-types';
import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
showLeadingZeros,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
};
+4
View File
@@ -28,3 +28,7 @@ $timer-finished-color: $playback-negative;
$timer-bold-font-family: 'Arial Black', sans-serif; // --card-background-color-override
$external-color: rgba(white, 70%); // --external-color-override
// properties of other timers (clock and countdown)
$timer-label-size: clamp(16px, 1.5vw, 24px);
$timer-value-size: clamp(2rem, 3.5vw, 3.5rem);
+5 -3
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langDe: TranslationObject = {
'common.end_time': 'Endzeit',
'common.expected_finish': 'Voraussichtliches Ende',
'common.expected_finish': 'Erwartetes Ende',
'common.minutes': 'min',
'common.now': 'Jetzt',
'common.next': 'Nächste',
'common.public_message': 'Öffentliche Nachricht',
'common.start_time': 'Startzeit',
'common.scheduled_start': 'Geplanter beginn',
'common.scheduled_end': 'Geplantes ende',
'common.projected_start': 'Erwartetes beginn',
'common.projected_end': 'Erwartetes ende',
'common.stage_timer': 'Bühnen-Timer',
'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit',
+4 -2
View File
@@ -1,11 +1,13 @@
export const langEn = {
'common.end_time': 'End Time',
'common.expected_finish': 'Expected Finish',
'common.minutes': 'min',
'common.now': 'Now',
'common.next': 'Next',
'common.public_message': 'Public message',
'common.start_time': 'Start Time',
'common.scheduled_start': 'Scheduled start',
'common.scheduled_end': 'Scheduled end',
'common.projected_start': 'Projected start',
'common.projected_end': 'Projected end',
'common.stage_timer': 'Stage Timer',
'common.started_at': 'Started At',
'common.time_now': 'Time now',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langEs: TranslationObject = {
'common.end_time': 'Hora de finalización',
'common.expected_finish': 'Finalización esperada',
'common.minutes': 'min',
'common.now': 'Ahora',
'common.next': 'Siguiente',
'common.public_message': 'Mensaje público',
'common.start_time': 'Hora de inicio',
'common.scheduled_start': 'Inicio programado',
'common.scheduled_end': 'Fin programado',
'common.projected_start': 'Inicio previsto',
'common.projected_end': 'Fin previsto',
'common.stage_timer': 'Temporizador de presentador',
'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langFr: TranslationObject = {
'common.end_time': 'Termine à',
'common.expected_finish': 'Fin estimée à',
'common.minutes': 'min',
'common.now': 'Maintenant',
'common.next': 'A suivre',
'common.public_message': 'Message public',
'common.start_time': 'Heure de début',
'common.scheduled_start': 'Début prévu',
'common.scheduled_end': 'Fin prévue',
'common.projected_start': 'Début projeté',
'common.projected_end': 'Fin projetée',
'common.stage_timer': 'Minuteur de scène',
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langIt: TranslationObject = {
'common.end_time': 'Ora di Fine',
'common.expected_finish': 'Fine Prevista',
'common.minutes': 'min',
'common.now': 'Adesso',
'common.next': 'Prossimo',
'common.public_message': 'Messaggio pubblico',
'common.start_time': 'Ora di Inizio',
'common.scheduled_start': 'Inizio programmato',
'common.scheduled_end': 'Fine programmata',
'common.projected_start': 'Inizio previsto',
'common.projected_end': 'Fine prevista',
'common.stage_timer': 'Orologio Palco',
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langNo: TranslationObject = {
'common.end_time': 'Sluttid',
'common.expected_finish': 'Forventet slutt',
'common.minutes': 'min',
'common.now': 'Nå',
'common.next': 'Neste',
'common.public_message': 'Offentlig beskjed',
'common.start_time': 'Starttid',
'common.scheduled_start': 'Planlagt start',
'common.scheduled_end': 'Planlagt slutt',
'common.projected_start': 'Forventet start',
'common.projected_end': 'Forventet slutt',
'common.stage_timer': 'Scenetimer',
'common.started_at': 'Startet',
'common.time_now': 'Klokken nå',
+4 -3
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langPl: TranslationObject = {
'common.end_time': 'Czas zakończenia',
'common.expected_finish': 'Zakładany czas zakończenia',
'common.minutes': 'min',
'common.now': 'Teraz',
'common.next': 'Następnie',
'common.public_message': 'Wiadomość publiczna',
'common.start_time': 'Czas rozpoczęcia',
'common.scheduled_start': 'Planowany początek',
'common.scheduled_end': 'Planowany koniec',
'common.projected_start': 'Przewidywany początek',
'common.projected_end': 'Przewidywany koniec',
'common.stage_timer': 'Timer Scena',
'common.started_at': 'Rozpoczęte o',
'common.time_now': 'Aktualny czas',
@@ -18,4 +20,3 @@ export const langPl: TranslationObject = {
'countdown.waiting': 'Oczekiwanie na start',
'countdown.overtime': 'ponad czasem',
};
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langPt: TranslationObject = {
'common.end_time': 'Hora de término',
'common.expected_finish': 'Término esperado',
'common.minutes': 'min',
'common.now': 'Agora',
'common.next': 'Próximo',
'common.public_message': 'Mensagem pública',
'common.start_time': 'Hora de início',
'common.scheduled_start': 'Início programado',
'common.scheduled_end': 'Fim programado',
'common.projected_start': 'Início previsto',
'common.projected_end': 'Fim previsto',
'common.stage_timer': 'Temporizador do presentador',
'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual',
+4 -2
View File
@@ -1,13 +1,15 @@
import { TranslationObject } from './en';
export const langSv: TranslationObject = {
'common.end_time': 'Sluttid',
'common.expected_finish': 'Förväntat slut',
'common.minutes': 'min',
'common.now': 'Nu',
'common.next': 'Nästa',
'common.public_message': 'Offentligt meddelande',
'common.start_time': 'Starttid',
'common.scheduled_start': 'Planerad start',
'common.scheduled_end': 'Planerad slut',
'common.projected_start': 'Beräknad start',
'common.projected_end': 'Beräknad slut',
'common.stage_timer': 'Timer för scenen',
'common.started_at': 'Började vid',
'common.time_now': 'Klockan nu',
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.3.3",
"version": "3.4.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -12,17 +12,18 @@
"license": "AGPL-3.0-only",
"main": "main.js",
"devDependencies": {
"electron": "^28.3.3",
"electron": "^31.2.0",
"electron-builder": "^24.13.3",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.0.3"
"prettier": "^3.0.3",
"wait-on": "^7.2.0"
},
"scripts": {
"postinstall": "",
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev": "cross-env NODE_ENV=development electron .",
"dev": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
+9 -10
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.3.3",
"version": "3.4.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -27,23 +27,22 @@
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/node": "^18.11.18",
"@types/node": "^20.14.10",
"@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@typescript-eslint/eslint-plugin": "^v7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"esbuild": "^0.19.10",
"eslint": "^8.56.0",
"eslint-plugin-prettier": "^5.1.3",
"nodemon": "^2.0.20",
"ontime-types": "workspace:*",
"prettier": "^3.3.1",
"server-timing": "^3.3.3",
"shx": "^0.3.4",
"ts-node": "^10.9.1",
"typescript": "^5.4.3",
"ts-essentials": "^9.4.1",
"tsx": "^4.16.2",
"typescript": "^5.5.3",
"vitest": "^1.6.0"
},
"scripts": {
@@ -51,9 +50,9 @@
"set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json",
"set:testproject": "shx cp ../../demo-db/db.json test-db/db.json",
"postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject",
"dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts",
"dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts",
"dev": "cross-env NODE_ENV=development tsx watch ./src/index.ts",
"dev:inspect": "cross-env NODE_ENV=development tsx watch --inspect ./src/index.ts",
"dev:test": "cross-env IS_TEST=true tsx ./src/index.ts",
"prebuild": "pnpm set:demoproject",
"build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs",
+58 -97
View File
@@ -8,32 +8,17 @@ import {
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { join } from 'path';
import { existsSync } from 'fs';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import { doesProjectExist, handleUploaded } from '../../services/project-service/projectServiceUtils.js';
import * as projectService from '../../services/project-service/ProjectService.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
if (failEmptyObjects(req.body, res)) {
res.status(400).send({ message: 'No field found to patch' });
return;
}
try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = req.body;
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
const newData = await projectService.applyDataModel(patchDb);
const newData = await projectService.patchCurrentProject(patchDb);
res.status(200).send(newData);
} catch (error) {
@@ -52,27 +37,20 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
* or a 500 status with an error message in case of an exception.
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
try {
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) {
return res.status(409).send({ message: 'Project with title already exists' });
}
const newProjectData: ProjectData = {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
publicUrl: req.body?.publicUrl ?? '',
publicInfo: req.body?.publicInfo ?? '',
backstageUrl: req.body?.backstageUrl ?? '',
backstageInfo: req.body?.backstageInfo ?? '',
};
await projectService.createProjectFile(filename, newProjectData);
const newFileName = await projectService.createProject(req.body.filename, newProjectData);
res.status(200).send({
filename,
filename: newFileName,
});
} catch (error) {
const message = getErrorMessage(error);
@@ -85,10 +63,8 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function projectDownload(req: Request, res: Response) {
const { filename } = req.body;
const pathToFile = join(resolveDbDirectory, filename);
// Check if the file exists before attempting to download
if (!existsSync(pathToFile)) {
const pathToFile = doesProjectExist(filename);
if (!pathToFile) {
return res.status(404).send({ message: `Project ${filename} not found.` });
}
@@ -102,6 +78,7 @@ export async function projectDownload(req: Request, res: Response) {
/**
* uploads, parses and applies the data from a given file
* Pretty much loadProject but with the extra upload step
*/
export async function postProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (!req.file) {
@@ -110,23 +87,18 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
}
try {
const options = req.query;
const { filename, path } = req.file;
await projectService.handleUploadedFile(path, filename);
await projectService.applyProjectFile(filename, options);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
await handleUploaded(path, filename);
await projectService.loadProjectFile(filename);
res.status(201).send({
message: `Loaded project ${filename}`,
});
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(400).send({ message });
}
}
@@ -150,23 +122,16 @@ export async function listProjects(_req: Request, res: Response<ProjectFileListR
export async function loadProject(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const name = req.body.filename;
if (!projectService.doesProjectExist(name)) {
return res.status(404).send({ message: 'File not found' });
}
await projectService.applyProjectFile(name);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
await projectService.loadProjectFile(name);
res.status(201).send({
message: `Loaded project ${name}`,
});
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
}
@@ -182,16 +147,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
* or a 500 status with an error message in case of an exception.
*/
export async function duplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
// file to copy from
const { filename } = req.params;
// new file name
const { newFilename } = req.body;
try {
const { filename } = req.params;
const { newFilename } = req.body;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.duplicateProjectFile(filename, newFilename);
res.status(201).send({
@@ -199,6 +160,10 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
});
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
}
@@ -215,16 +180,9 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
*/
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
const { filename: newFilename } = req.body;
const { newFilename } = req.body;
const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
// Rename the file
await projectService.renameProjectFile(filename, newFilename);
res.status(201).send({
@@ -232,6 +190,10 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
});
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
return res.status(403).send({ message });
}
res.status(500).send({ message });
}
}
@@ -247,33 +209,32 @@ export async function renameProjectFile(req: Request, res: Response<MessageRespo
* or a 500 status with an error message in case of an exception.
*/
export async function deleteProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
const { filename } = req.params;
try {
const { filename } = req.params;
const { lastLoadedProject } = await appStateService.get();
if (lastLoadedProject === filename) {
return res.status(403).send({ message: 'Cannot delete currently loaded project' });
}
const errors = projectService.validateProjectFiles({ filename });
if (errors.length) {
return res.status(409).send({ message: errors.join(', ') });
}
await projectService.deleteProjectFile(filename);
res.status(204).send({
res.status(200).send({
message: `Deleted project ${filename}`,
});
} catch (error) {
const message = getErrorMessage(error);
if (message === 'Cannot delete currently loaded project') {
return res.status(403).send({ message });
}
if (message === 'Project file not found') {
return res.status(404).send({ message });
}
res.status(500).send({ message });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo | ErrorResponse>) {
try {
const info = await projectService.getInfo();
res.status(200).send(info);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
}
}
export async function getInfo(_req: Request, res: Response<GetInfo>) {
const info = await projectService.getInfo();
res.status(200).send(info);
}
+1 -1
View File
@@ -33,7 +33,7 @@ router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject);
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
router.get('/info', getInfo);
+10 -1
View File
@@ -26,13 +26,22 @@ export const validateNewProject = [
* @description Validates request for pathing data in the project.
*/
export const validatePatchProject = [
// Custom validator to ensure the body is not empty
(req: Request, res: Response, next: NextFunction) => {
if (Object.keys(req.body).length === 0) {
return res.status(422).json({ errors: [{ msg: 'Request body cannot be empty' }] });
}
next();
},
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('urlPresets').isArray().optional({ nullable: false }),
body('customFields').isObject().optional({ nullable: false }),
body('osc').isObject().optional({ nullable: false }),
body('http').isObject().optional({ nullable: false }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -11,7 +11,7 @@ import { existsSync } from 'fs';
import xlsx from 'node-xlsx';
import { parseExcel } from '../../utils/parser.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
let excelData: { name: string; data: unknown[][] }[] = [];
@@ -42,11 +42,10 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
const dataFromExcel = parseExcel(data, options);
// we run the parsed data through an extra step to ensure the objects shape
const rundown = parseRundown(dataFromExcel);
const { rundown, customFields } = parseRundown(dataFromExcel);
if (rundown.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
const customFields = parseCustomFields(dataFromExcel);
// clear the data
excelData = [];
@@ -1,14 +1,14 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = DataProvider.getHttp();
const http = getDataProvider().getHttp();
res.status(200).send(http);
}
@@ -22,7 +22,7 @@ export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorR
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setHttp(httpSettings);
const result = await getDataProvider().setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,14 +1,14 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = DataProvider.getOsc();
const osc = getDataProvider().getOsc();
res.status(200).send(osc);
}
@@ -22,7 +22,7 @@ export async function postOSC(req: Request, res: Response<OSCSettings | ErrorRes
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await DataProvider.setOsc(oscSettings);
const result = await getDataProvider().setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
@@ -1,13 +1,14 @@
import { ErrorResponse, ProjectData } from 'ontime-types';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { removeUndefined } from '../../utils/parserUtils.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getProjectData(_req: Request, res: Response<ProjectData>) {
res.json(DataProvider.getProjectData());
res.json(getDataProvider().getProjectData());
}
export async function postProjectData(req: Request, res: Response<ProjectData | ErrorResponse>) {
@@ -25,7 +26,7 @@ export async function postProjectData(req: Request, res: Response<ProjectData |
backstageInfo: req.body?.backstageInfo,
endMessage: req.body?.endMessage,
});
const newData = await DataProvider.setProjectData(newEvent);
const newData = await getDataProvider().setProjectData(newEvent);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,4 +1,12 @@
import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types';
import {
ErrorResponse,
MessageResponse,
OntimeRundown,
OntimeRundownEntry,
RundownCached,
RundownPaginated,
} from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { Request, Response } from 'express';
@@ -13,8 +21,12 @@ import {
reorderEvent,
swapEvents,
} from '../../services/rundown-service/RundownService.js';
import { getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js';
import { getErrorMessage } from 'ontime-utils';
import {
getEventWithId,
getNormalisedRundown,
getPaginated,
getRundown,
} from '../../services/rundown-service/rundownUtils.js';
export async function rundownGetAll(_req: Request, res: Response<OntimeRundown>) {
const rundown = getRundown();
@@ -26,6 +38,51 @@ export async function rundownGetNormalised(_req: Request, res: Response<RundownC
res.json(cachedRundown);
}
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
const { eventId } = req.params;
try {
const event = getEventWithId(eventId);
if (!event) {
res.status(404).send({ message: 'Event not found' });
return;
}
res.status(200).json(event);
} catch (error) {
const message = getErrorMessage(error);
res.status(500).json({ message });
}
}
export async function rundownGetPaginated(req: Request, res: Response<RundownPaginated | ErrorResponse>) {
const { limit, offset } = req.query;
if (limit == null && offset == null) {
return res.json({
rundown: getRundown(),
total: getRundown().length,
});
}
try {
let parsedOffset = Number(offset);
if (Number.isNaN(parsedOffset)) {
parsedOffset = 0;
}
let parsedLimit = Number(limit);
if (Number.isNaN(parsedLimit)) {
parsedLimit = Infinity;
}
const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
res.status(200).json(paginatedRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).json({ message });
}
}
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
@@ -6,7 +6,9 @@ import {
rundownBatchPut,
rundownDelete,
rundownGetAll,
rundownGetById,
rundownGetNormalised,
rundownGetPaginated,
rundownPost,
rundownPut,
rundownReorder,
@@ -16,6 +18,7 @@ import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
@@ -25,7 +28,9 @@ import {
export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend
router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
router.get('/normalised', rundownGetNormalised);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
@@ -1,4 +1,4 @@
import { body, param, validationResult } from 'express-validator';
import { body, param, query, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [
@@ -75,3 +75,14 @@ export const rundownArrayOfIds = [
next();
},
];
export const rundownGetPaginatedQueryParams = [
query('offset').isNumeric().optional(),
query('limit').isNumeric().optional(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,15 +1,16 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { Request, Response } from 'express';
import type { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { extractPin } from './settings.utils.js';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
obfuscatedSettings.editorKey = obfuscate(settings.editorKey);
@@ -27,7 +28,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
return;
}
try {
const settings = DataProvider.getSettings();
const settings = getDataProvider().getSettings();
const editorKey = extractPin(req.body?.editorKey, settings.editorKey);
const operatorKey = extractPin(req.body?.operatorKey, settings.operatorKey);
const serverPort = Number(req.body?.serverPort);
@@ -57,7 +58,7 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
language,
serverPort,
};
await DataProvider.setSettings(newData);
await getDataProvider().setSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -0,0 +1,15 @@
/**
* Business logic for resolving a string
*/
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
@@ -1,13 +1,13 @@
import type { ErrorResponse, URLPreset } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failIsNotArray } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getUrlPresets(_req: Request, res: Response<URLPreset[]>) {
const presets = DataProvider.getUrlPresets();
const presets = getDataProvider().getUrlPresets();
res.status(200).send(presets as URLPreset[]);
}
@@ -21,7 +21,7 @@ export async function postUrlPresets(req: Request, res: Response<URLPreset[] | E
alias: preset.alias,
pathAndParams: preset.pathAndParams,
}));
await DataProvider.setUrlPresets(newPresets);
await getDataProvider().setUrlPresets(newPresets);
res.status(200).send(newPresets);
} catch (error) {
const message = getErrorMessage(error);
@@ -1,31 +1,26 @@
import type { ErrorResponse, ViewSettings } from 'ontime-types';
import { Request, Response } from 'express';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getViewSettings(_req: Request, res: Response<ViewSettings>) {
const views = DataProvider.getViewSettings();
const views = getDataProvider().getViewSettings();
res.status(200).send(views);
}
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const newData = {
dangerColor: req.body.dangerColor,
endMessage: req.body?.endMessage ?? '',
endMessage: req.body.endMessage,
freezeEnd: req.body.freezeEnd,
normalColor: req.body.normalColor,
overrideStyles: req.body.overrideStyles,
warningColor: req.body.warningColor,
};
await DataProvider.setViewSettings(newData);
} as ViewSettings;
await getDataProvider().setViewSettings(newData);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -5,11 +5,12 @@ import { Request, Response, NextFunction } from 'express';
* @description Validates object for POST /ontime/views
*/
export const validateViewSettings = [
check('overrideStyles').isBoolean().withMessage('overrideStyles value must be boolean'),
check('endMessage').isString().trim().withMessage('endMessage value must be string'),
check('normalColor').isString().trim().withMessage('normalColor value must be string'),
check('warningColor').isString().trim().withMessage('warningColor value must be string'),
check('dangerColor').isString().trim().withMessage('dangerColor value must be string'),
check('dangerColor').exists().isString().trim().withMessage('dangerColor value must be string'),
check('endMessage').exists().isString().trim().withMessage('endMessage value must be string'),
check('freezeEnd').exists().isBoolean().withMessage('freezeEnd value must be boolean'),
check('normalColor').exists().isString().trim().withMessage('normalColor value must be string'),
check('overrideStyles').exists().isBoolean().withMessage('overrideStyles value must be boolean'),
check('warningColor').exists().isString().trim().withMessage('warningColor value must be string'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -55,16 +55,17 @@ const actionHandlers: Record<string, ActionHandler> = {
throw new Error('Invalid property or value');
}
const newObjectProperty = parseProperty(property, value);
// parseProperty is async because of the data lock
parseProperty(property, value).then((newObjectProperty) => {
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
} else {
Object.assign(patchEvent, newObjectProperty);
}
});
});
if (shouldThrottle) {
@@ -1,17 +1,17 @@
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
/**
*
* @param {number} value time amount in seconds
* @returns {number} time in milliseconds clamped to 0 and max duration
*/
function clampDuration(value: number) {
function clampDuration(value: number): number {
const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59');
@@ -42,10 +42,11 @@ const propertyConversion = {
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
};
export function parseProperty(property: string, value: unknown) {
export async function parseProperty(property: string, value: unknown) {
if (property.startsWith('custom:')) {
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
if (!(customKey in DataProvider.getCustomFields())) {
const customFields = getDataProvider().getCustomFields();
if (!(customKey in customFields)) {
throw new Error(`Custom field ${customKey} not found`);
}
const parserFn = propertyConversion.custom;
+13 -13
View File
@@ -1,4 +1,4 @@
import { HttpSettings, LogOrigin, OSCSettings, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { LogOrigin, Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import 'dotenv/config';
import express from 'express';
@@ -13,10 +13,10 @@ import {
srcDirectory,
environment,
isProduction,
resolveDbPath,
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
resolvePublicDirectoy,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
@@ -27,8 +27,7 @@ import { integrationRouter } from './api-integration/integration.router.js';
// Import adapters
import { socket } from './adapters/WebsocketAdapter.js';
import { DataProvider } from './classes/data-provider/DataProvider.js';
import { dbLoadingProcess } from './setup/loadDb.js';
import { getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
import { integrationService } from './services/integration-service/IntegrationService.js';
@@ -43,6 +42,7 @@ import { messageService } from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
import { initialiseProject } from './services/project-service/ProjectService.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
@@ -55,8 +55,8 @@ consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
const canLog = isProduction;
if (!canLog) {
console.log(`Ontime running in ${environment} environment`);
console.log(`Ontime directory at ${srcDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`);
console.log(`Ontime source directory at ${srcDirectory} `);
console.log(`Ontime public directory at ${resolvePublicDirectoy} `);
}
// Create express APP
@@ -149,10 +149,11 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder();
populateStyles();
populateDemo();
const project = await initialiseProject();
logger.info(LogOrigin.Server, `Initialised Ontime with ${project}`);
};
/**
@@ -162,8 +163,7 @@ export const startServer = async (
escalateErrorFn?: (error: string) => void,
): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
const { serverPort } = DataProvider.getSettings();
const { serverPort } = getDataProvider().getSettings();
expressServer = http.createServer(app);
socket.init(expressServer);
@@ -194,8 +194,8 @@ export const startServer = async (
logger.init(escalateErrorFn);
// initialise rundown service
const persistedRundown = DataProvider.getRundown();
const persistedCustomFields = DataProvider.getCustomFields();
const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
// load restore point if it exists
@@ -225,11 +225,11 @@ export const startServer = async (
/**
* starts integrations
*/
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
export const startIntegrations = async () => {
checkStart(OntimeStartOrder.InitIO);
// if a config is not provided, we use the persisted one
const { osc, http } = config ?? DataProvider.getData();
const { osc, http } = getDataProvider().getData();
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
@@ -1,7 +1,3 @@
/**
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import {
ProjectData,
OntimeRundown,
@@ -14,117 +10,165 @@ import {
URLPreset,
} from 'ontime-types';
import { data, db } from '../../setup/loadDb.js';
import type { Low } from 'lowdb';
import { JSONFilePreset } from 'lowdb/node';
import { isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
import { safeMerge } from './DataProvider.utils.js';
import { isTest } from '../../setup/index.js';
type ReadonlyPromise<T> = Promise<Readonly<T>>;
export class DataProvider {
static getData() {
return data;
}
let db = {} as Low<DatabaseModel>;
static async setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
data.project = { ...data.project, ...newData };
this.persist();
return data.project;
}
static getProjectData(): Readonly<ProjectData> {
return data.project;
}
static async setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
data.customFields = { ...newData };
this.persist();
return data.customFields;
}
static getCustomFields(): Readonly<CustomFields> {
return data.customFields;
}
static async setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
data.rundown = [...newData];
this.persist();
return data.rundown;
}
static getSettings(): Readonly<Settings> {
return data.settings;
}
static async setSettings(newData: Settings): ReadonlyPromise<Settings> {
data.settings = { ...newData };
this.persist();
return data.settings;
}
static getOsc(): Readonly<OSCSettings> {
return data.osc;
}
static getHttp(): Readonly<HttpSettings> {
return data.http;
}
static getUrlPresets(): Readonly<URLPreset[]> {
return data.urlPresets;
}
static async setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
data.urlPresets = newData;
this.persist();
return data.urlPresets;
}
static getViewSettings(): Readonly<ViewSettings> {
return data.viewSettings;
}
static async setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
data.viewSettings = { ...newData };
this.persist();
return data.viewSettings;
}
static async setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
data.osc = { ...newData };
this.persist();
return data.osc;
}
static async setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
data.http = { ...newData };
this.persist();
return data.http;
}
static getRundown(): Readonly<OntimeRundown> {
return data.rundown;
}
private static async persist() {
if (isTest) {
return;
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
if (!isProduction) {
if (!isPath(filePath)) {
consoleError(filePath);
consoleError(new Error('initPersistence should be called with a path').stack);
process.exit(0);
}
await db.write();
}
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
static async mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
const mergedData = safeMerge(data, newData);
data.project = mergedData.project;
data.settings = mergedData.settings;
data.viewSettings = mergedData.viewSettings;
data.osc = mergedData.osc;
data.http = mergedData.http;
data.urlPresets = mergedData.urlPresets;
data.customFields = mergedData.customFields;
data.rundown = mergedData.rundown;
// Read the database to initialize it
newDb.data = fallbackData;
await newDb.write();
await newDb.read();
this.persist();
return data;
}
db = newDb;
}
export function getDataProvider() {
if (db === null) throw new Error('Database not initialized');
return {
getData,
setProjectData,
getProjectData,
setCustomFields,
getCustomFields,
setRundown,
getSettings,
setSettings,
getOsc,
getHttp,
getUrlPresets,
setUrlPresets,
getViewSettings,
setViewSettings,
setOsc,
setHttp,
getRundown,
mergeIntoData,
};
}
function getData(): Readonly<DatabaseModel> {
return db.data;
}
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
db.data.project = { ...db.data.project, ...newData };
await persist();
return db.data.project;
}
function getProjectData(): Readonly<ProjectData> {
return db.data.project;
}
async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
db.data.customFields = { ...newData };
await persist();
return db.data.customFields;
}
function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
db.data.rundown = [...newData];
await persist();
return db.data.rundown;
}
function getSettings(): Readonly<Settings> {
return db.data.settings;
}
async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
db.data.settings = { ...newData };
await persist();
return db.data.settings;
}
function getOsc(): Readonly<OSCSettings> {
return db.data.osc;
}
function getHttp(): Readonly<HttpSettings> {
return db.data.http;
}
function getUrlPresets(): Readonly<URLPreset[]> {
return db.data.urlPresets;
}
async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
db.data.urlPresets = newData;
await persist();
return db.data.urlPresets;
}
function getViewSettings(): Readonly<ViewSettings> {
return db.data.viewSettings;
}
async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
db.data.viewSettings = { ...newData };
await persist();
return db.data.viewSettings;
}
async function setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
db.data.osc = { ...newData };
await persist();
return db.data.osc;
}
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
db.data.http = { ...newData };
await persist();
return db.data.http;
}
function getRundown(): Readonly<OntimeRundown> {
return db.data.rundown;
}
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
const mergedData = safeMerge(db.data, newData);
db.data.project = mergedData.project;
db.data.settings = mergedData.settings;
db.data.viewSettings = mergedData.viewSettings;
db.data.osc = mergedData.osc;
db.data.http = mergedData.http;
db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields;
db.data.rundown = mergedData.rundown;
await persist();
return db.data;
}
/**
* Handles persisting data to file
*/
async function persist() {
if (isTest) return;
await db.write();
}
@@ -1,11 +1,9 @@
import { DatabaseModel } from 'ontime-types';
/**
* Merges two data objects
* @param {object} existing
* @param {object} newData
* Merges a partial ontime project into a given ontime project
*/
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http } = newData || {};
return {
+409
View File
@@ -0,0 +1,409 @@
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = {
rundown: [
{
type: SupportedEvent.Event,
id: '32d31',
cue: 'SF1.01',
title: 'Albania',
note: 'SF1.01',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 36000000,
timeEnd: 37200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
},
},
{
type: SupportedEvent.Event,
id: '21cd2',
cue: 'SF1.02',
title: 'Latvia',
note: 'SF1.02',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 37500000,
timeEnd: 38700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Eat Your Salad',
artist: 'Citi Zeni',
},
},
{
type: SupportedEvent.Event,
id: '0b371',
cue: 'SF1.03',
title: 'Lithuania',
note: 'SF1.03',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 39000000,
timeEnd: 40200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Sentimentai',
artist: 'Monika Liu',
},
},
{
type: SupportedEvent.Event,
id: '3cd28',
cue: 'SF1.04',
title: 'Switzerland',
note: 'SF1.04',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 40500000,
timeEnd: 41700000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Boys Do Cry',
artist: 'Marius Bear',
},
},
{
type: SupportedEvent.Event,
id: 'e457f',
cue: 'SF1.05',
title: 'Slovenia',
note: 'SF1.05',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 42000000,
timeEnd: 43200000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Disko',
artist: 'LPS',
},
},
{
type: SupportedEvent.Block,
id: '01e85',
title: 'Lunch break',
},
{
type: SupportedEvent.Event,
id: '1c420',
cue: 'SF1.06',
title: 'Ukraine',
note: 'SF1.06',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 47100000,
timeEnd: 48300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Stefania',
artist: 'Kalush Orchestra',
},
},
{
type: SupportedEvent.Event,
id: 'b7737',
cue: 'SF1.07',
title: 'Bulgaria',
note: 'SF1.07',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 48600000,
timeEnd: 49800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Intention',
artist: 'Intelligent Music Project',
},
},
{
type: SupportedEvent.Event,
id: 'd3a80',
cue: 'SF1.08',
title: 'Netherlands',
note: 'SF1.08',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 50100000,
timeEnd: 51300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'De Diepte',
artist: 'S10',
},
},
{
type: SupportedEvent.Event,
id: '8276c',
cue: 'SF1.09',
title: 'Moldova',
note: 'SF1.09',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 51600000,
timeEnd: 52800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Trenuletul',
artist: 'Zdob si Zdub',
},
},
{
type: SupportedEvent.Event,
id: '2340b',
cue: 'SF1.10',
title: 'Portugal',
note: 'SF1.10',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 53100000,
timeEnd: 54300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Saudade Saudade',
artist: 'Maro',
},
},
{
type: SupportedEvent.Block,
id: 'cb90b',
title: 'Afternoon break',
},
{
type: SupportedEvent.Event,
id: '503c4',
cue: 'SF1.11',
title: 'Croatia',
note: 'SF1.11',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 56100000,
timeEnd: 57300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Guilty Pleasure',
artist: 'Mia Dimsic',
},
},
{
type: SupportedEvent.Event,
id: '5e965',
cue: 'SF1.12',
title: 'Denmark',
note: 'SF1.12',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 57600000,
timeEnd: 58800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'The Show',
artist: 'Reddi',
},
},
{
type: SupportedEvent.Event,
id: 'bab4a',
cue: 'SF1.13',
title: 'Austria',
note: 'SF1.13',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 59100000,
timeEnd: 60300000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Halo',
artist: 'LUM!X & Pia Maria',
},
},
{
type: SupportedEvent.Event,
id: 'd3eb1',
cue: 'SF1.14',
title: 'Greece',
note: 'SF1.14',
endAction: EndAction.None,
timerType: TimerType.CountDown,
linkStart: null,
timeStrategy: TimeStrategy.LockEnd,
timeStart: 60600000,
timeEnd: 61800000,
duration: 1200000,
isPublic: true,
skip: false,
colour: '',
revision: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
song: 'Die Together',
artist: 'Amanda Tenfjord',
},
},
],
project: {
title: 'Eurovision Song Contest',
description: 'Turin 2022',
publicUrl: 'www.getontime.no',
publicInfo: 'Rehearsal Schedule - Turin 2022',
backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
},
settings: {
app: 'ontime',
version: '3.3.2',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
dangerColor: '#ED3333',
endMessage: '',
freezeEnd: false,
normalColor: '#ffffffcc',
overrideStyles: false,
warningColor: '#FFAB33',
},
customFields: {
song: {
label: 'Song',
type: 'string',
colour: '#339E4E',
},
artist: {
label: 'Artist',
type: 'string',
colour: '#3E75E8',
},
},
urlPresets: [
{
enabled: true,
alias: 'test',
pathAndParams: 'lower?bg=ff2&text=f00&size=0.6&transition=5',
},
],
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: true,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
};
+2 -2
View File
@@ -8,12 +8,12 @@ import { timerConfig } from '../config/config.js';
* Service manages Ontime's main timer
*/
export class TimerService {
private readonly _interval: NodeJS.Timer;
private readonly _interval: NodeJS.Timeout;
/** how often we recalculate */
static _refreshInterval: number;
/** when timer will be finished */
private endCallback: NodeJS.Timer;
private endCallback: NodeJS.Timeout;
private onUpdateCallback: (updateResult: UpdateResult) => void;
@@ -1558,7 +1558,7 @@ describe('getRuntimeOffset()', () => {
} as RuntimeState;
const offset = getRuntimeOffset(state);
expect(offset).toBe(null);
expect(offset).toBe(0);
});
it('handles loaded event', () => {
@@ -1,51 +1,40 @@
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { appStatePath, isTest } from '../../setup/index.js';
import { appStatePath, isProduction, isTest } from '../../setup/index.js';
import { isPath } from '../../utils/fileManagement.js';
import { consoleError } from '../../utils/console.js';
interface Config {
lastLoadedProject: string;
interface AppState {
lastLoadedProject?: string;
}
/**
* Service manages Ontime's runtime memory between boots
*/
const adapter = new JSONFile<AppState>(appStatePath);
const config = new Low<AppState>(adapter, {});
class AppStateService {
private config: Low<Config>;
private pathToFile: string;
private didInit = false;
constructor(appStatePath: string) {
this.pathToFile = appStatePath;
const adapter = new JSONFile<Config>(this.pathToFile);
this.config = new Low<Config>(adapter, null);
}
private async init() {
await this.config.read();
await this.config.write();
this.didInit = true;
}
async get(): Promise<Config> {
if (!this.didInit) {
await this.init();
}
await this.config.read();
return this.config.data;
}
async updateDatabaseConfig(filename: string): Promise<void> {
if (isTest) return;
if (!this.didInit) {
await this.init();
}
this.config.data.lastLoadedProject = filename;
await this.config.write();
}
export async function isLastLoadedProject(projectName: string): Promise<boolean> {
const lastLoaded = await getLastLoadedProject();
return lastLoaded === projectName;
}
export const appStateService = new AppStateService(appStatePath);
export async function getLastLoadedProject(): Promise<string | undefined> {
// in test environment, we want to start the demo project
if (isTest) return;
await config.read();
return config.data.lastLoadedProject;
}
export async function setLastLoadedProject(filename: string): Promise<void> {
if (isTest) return;
if (!isProduction) {
if (isPath(filename)) {
consoleError(filename);
consoleError(new Error('setLastLoadedProject should not be called with a path').stack);
process.exit(0);
}
}
config.data.lastLoadedProject = filename;
await config.write();
}
@@ -8,7 +8,7 @@ export type GetTimeFn = () => number;
export class AuxTimerService {
private timer: SimpleTimer;
private interval: NodeJS.Timer | null = null;
private interval: NodeJS.Timeout | null = null;
private emit: EmitFn;
private getTime: GetTimeFn;
@@ -1,20 +1,44 @@
import { DatabaseModel, GetInfo, ProjectData, ProjectFile, ProjectFileListResponse } from 'ontime-types';
import { DatabaseModel, GetInfo, LogOrigin, ProjectData, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { copyFile, rename, stat, writeFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { copyFile, rename } from 'fs/promises';
import { initRundown } from '../rundown-service/RundownService.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { logger } from '../../classes/Logger.js';
import { getNetworkInterfaces } from '../../utils/networkInterfaces.js';
import { resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
import { filterProjectFiles, parseProjectFile } from './projectFileUtils.js';
import { appStateService } from '../app-state-service/AppStateService.js';
import { ensureDirectory, getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
import { resolveCorruptDirectory, resolveProjectsDirectory, resolveStylesPath } from '../../setup/index.js';
import {
appendToName,
ensureDirectory,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
} from '../../utils/fileManagement.js';
import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { switchDb } from '../../setup/loadDb.js';
import { parseDatabaseModel } from '../../utils/parser.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
getLastLoadedProject,
isLastLoadedProject,
setLastLoadedProject,
} from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { oscIntegration } from '../integration-service/OscIntegration.js';
import { httpIntegration } from '../integration-service/HttpIntegration.js';
import {
copyCorruptFile,
doesProjectExist,
getPathToProject,
getProjectFiles,
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
// init dependencies
init();
@@ -24,69 +48,135 @@ init();
*/
function init() {
ensureDirectory(resolveProjectsDirectory);
}
type Options = {
onlyRundown?: 'true' | 'false';
};
/**
* Handles a file from the upload folder and applies its data
*/
export async function applyProjectFile(name: string, options?: Options) {
const filePath = join(resolveProjectsDirectory, name);
const data = parseProjectFile(filePath);
// change LowDB to point to new file
await switchDb(name);
// apply data model
await applyDataModel(data, options);
// persist the project selection
await appStateService.updateDatabaseConfig(name);
ensureDirectory(resolveCorruptDirectory);
}
/**
* Copies a file from upload folder to the projects folder
* @param filePath
* @param name
* @returns
* Private function loads a demo project
* to be composed in the loading functions
*/
export async function handleUploadedFile(filePath: string, name: string) {
const newFilePath = join(resolveProjectsDirectory, name);
await rename(filePath, newFilePath);
await deleteFile(filePath);
return name;
async function loadDemoProject(): Promise<string> {
const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.demoProject);
await initPersistence(getPathToProject(pathToNewFile), demoDb);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
/**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
*
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation or overwriting time (updatedAt)
*
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
* Private function loads a new, empty project
* to be composed in the loading functions
*/
export async function getProjectFiles(): Promise<ProjectFile[]> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
const filteredFiles = filterProjectFiles(allFiles);
async function loadNewProject(): Promise<string> {
const pathToNewFile = generateUniqueFileName(resolveProjectsDirectory, config.newProject);
await initPersistence(getPathToProject(pathToNewFile), dbModel);
const newName = getFileNameFromPath(pathToNewFile);
await setLastLoadedProject(newName);
return newName;
}
const projectFiles: ProjectFile[] = [];
for (const file of filteredFiles) {
const filePath = join(resolveProjectsDirectory, file);
const stats = await stat(filePath);
/**
* Private function handles side effects on currupted files
* Corrupted files in this context contain data that failed domain validation
*/
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
// copy file to corrupted folder
await copyCorruptFile(filePath, fileName).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
projectFiles.push({
filename: removeFileExtension(file),
updatedAt: stats.mtime.toISOString(),
});
// and make a new file with the recovered data
const newPath = appendToName(filePath, '(recovered)');
await rename(filePath, newPath);
return getFileNameFromPath(newPath);
}
/**
* Coordinates the initial load of a project on app startup
* This is different from the load project since we need to always load something
* @returns {Promise<string>} - name of the loaded file
*/
export async function initialiseProject(): Promise<string> {
// check what was loaded before
const previousProject = await getLastLoadedProject();
if (!previousProject) {
return loadDemoProject();
}
return projectFiles;
// try and load the previous project
const filePath = doesProjectExist(previousProject);
if (filePath === null) {
logger.warning(LogOrigin.Server, `Previous project file ${previousProject} not found`);
return loadNewProject();
}
try {
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = previousProject;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, previousProject);
parsedFilePath = getPathToProject(parsedFileName);
}
await initPersistence(parsedFilePath, result.data);
await setLastLoadedProject(parsedFileName);
return parsedFileName;
} catch (error) {
logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`);
await moveCorruptFile(filePath, previousProject).catch((_) => {
/* while we have to catch the error, we dont need to handle it */
});
return loadNewProject();
}
}
/**
* Loads a data from a file into the runtime
*/
export async function loadProjectFile(name: string) {
const filePath = doesProjectExist(name);
if (filePath === null) {
throw new Error('Project file not found');
}
// when loading a project file, we allow parsing to fail and interrupt the process
const fileData = await parseJsonFile(filePath);
const result = parseDatabaseModel(fileData);
let parsedFileName = name;
let parsedFilePath = filePath;
if (result.errors.length > 0) {
logger.warning(LogOrigin.Server, 'Project loaded with errors');
parsedFileName = await handleCorruptedFile(filePath, name);
parsedFilePath = getPathToProject(parsedFileName);
}
// change LowDB to point to new file
await initPersistence(parsedFilePath, result.data);
logger.info(LogOrigin.Server, `Loaded project ${parsedFileName}`);
// persist the project selection
await setLastLoadedProject(parsedFileName);
// since load happens at runtime, we need to update the services that depend on the data
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
// apply the rundown
initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
}
/**
@@ -94,47 +184,81 @@ export async function getProjectFiles(): Promise<ProjectFile[]> {
*/
export async function getProjectList(): Promise<ProjectFileListResponse> {
const files = await getProjectFiles();
const appState = await appStateService.get();
const lastLoadedProject = removeFileExtension(appState.lastLoadedProject);
const lastLoadedProject = await getLastLoadedProject();
return {
files,
lastLoadedProject,
lastLoadedProject: lastLoadedProject ? removeFileExtension(lastLoadedProject) : '',
};
}
/**
* Duplicates an existing project file
*/
export async function duplicateProjectFile(existingProjectFile: string, newProjectFile: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
const duplicateProjectFilePath = join(resolveProjectsDirectory, newProjectFile);
export async function duplicateProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
return copyFile(projectFilePath, duplicateProjectFilePath);
const duplicateProjectFilePath = doesProjectExist(newFilename);
if (duplicateProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToDuplicate = getPathToProject(newFilename);
return copyFile(projectFilePath, pathToDuplicate);
}
/**
* Renames an existing project file
*/
export async function renameProjectFile(existingProjectFile: string, newName: string) {
const projectFilePath = join(resolveProjectsDirectory, existingProjectFile);
const newProjectFilePath = join(resolveProjectsDirectory, newName);
export async function renameProjectFile(originalFile: string, newFilename: string) {
const projectFilePath = doesProjectExist(originalFile);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
await rename(projectFilePath, newProjectFilePath);
const newProjectFilePath = doesProjectExist(newFilename);
if (newProjectFilePath !== null) {
throw new Error(`Project file with name ${newFilename} already exists`);
}
const pathToRenamed = getPathToProject(newFilename);
await rename(projectFilePath, pathToRenamed);
// Update the last loaded project config if current loaded project is the one being renamed
const { lastLoadedProject } = await appStateService.get();
const isLoaded = await isLastLoadedProject(originalFile);
if (isLoaded) {
const fileData = await parseJsonFile(pathToRenamed);
const result = parseDatabaseModel(fileData);
if (lastLoadedProject === existingProjectFile) {
await appStateService.updateDatabaseConfig(newName);
// change LowDB to point to new file
await initPersistence(pathToRenamed, result.data);
logger.info(LogOrigin.Server, `Loaded project ${newFilename}`);
// persist the project selection
await setLastLoadedProject(newFilename);
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
// apply the rundown
initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
}
}
/**
* Creates a new project file and applies its result
*/
export async function createProjectFile(filename: string, projectData: ProjectData) {
const data = {
export async function createProject(filename: string, projectData: ProjectData) {
const data: DatabaseModel = {
...dbModel,
project: {
...dbModel.project,
@@ -142,24 +266,36 @@ export async function createProjectFile(filename: string, projectData: ProjectDa
},
};
// create new file
const newFile = join(resolveProjectsDirectory, filename);
await writeFile(newFile, JSON.stringify(data));
const uniqueFileName = generateUniqueFileName(resolveProjectsDirectory, filename);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
await switchDb(filename);
await initPersistence(newFile, data);
// apply its data
await applyDataModel(data);
// apply data to running services
// we dont need to parse since we are creating a new file
await patchCurrentProject(data);
appStateService.updateDatabaseConfig(filename);
// update app state to point to new value
setLastLoadedProject(uniqueFileName);
return uniqueFileName;
}
/**
* Deletes a project file
*/
export async function deleteProjectFile(filename: string) {
const projectFilePath = join(resolveProjectsDirectory, filename);
const isPreviousProject = await isLastLoadedProject(filename);
if (isPreviousProject) {
throw new Error('Cannot delete currently loaded project');
}
const projectFilePath = doesProjectExist(filename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
await deleteFile(projectFilePath);
}
@@ -167,8 +303,8 @@ export async function deleteProjectFile(filename: string) {
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = DataProvider.getSettings();
const osc = DataProvider.getOsc();
const { version, serverPort } = getDataProvider().getSettings();
const osc = getDataProvider().getOsc();
// get nif and inject localhost
const ni = getNetworkInterfaces();
@@ -184,83 +320,22 @@ export async function getInfo(): Promise<GetInfo> {
};
}
/**
* Business logic for resolving a string
*/
export function extractPin(value: string | undefined | null, fallback: string | null): string | null {
if (value === null) {
return value;
}
if (typeof value === 'undefined') {
return fallback;
}
if (value.length === 0) {
return null;
}
return value;
}
/**
* applies a partial database model
*/
export async function applyDataModel(data: Partial<DatabaseModel>, _options?: Options) {
export async function patchCurrentProject(data: Partial<DatabaseModel>) {
runtimeService.stop();
// TODO: allow partial project merge from options
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging
const { rundown, customFields, ...rest } = data;
const newData = await DataProvider.mergeIntoData(rest);
// we can pass some stuff straight to the data provider
const newData = await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundown != null) {
initRundown(rundown, customFields ?? {});
const result = parseRundown(data);
initRundown(result.rundown, result.customFields);
}
return newData;
}
/**
* Checks whether a project of a given name exists
* @param name
*/
export function doesProjectExist(name: string): boolean {
const projectFilePath = join(resolveProjectsDirectory, name);
return existsSync(projectFilePath);
}
/**
* @description Validates the existence of project files.
* @param {object} projectFiles
* @param {string} projectFiles.projectFilename
* @param {string} projectFiles.newFilename
*
* @returns {Promise<Array<string>>} Array of errors
*
*/
export const validateProjectFiles = (projectFiles: { filename?: string; newFilename?: string }): Array<string> => {
const errors: string[] = [];
if (projectFiles.filename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.filename);
if (!existsSync(projectFilePath)) {
errors.push('Project file does not exist');
}
}
if (projectFiles.newFilename) {
const projectFilePath = join(resolveProjectsDirectory, projectFiles.newFilename);
if (existsSync(projectFilePath)) {
errors.push('New project file already exists');
}
}
return errors;
};
/**
* Get current project title or fallback
*/
export function getProjectTitle(): string {
const { title } = DataProvider.getProjectData();
return title || 'ontime data';
}
@@ -1,33 +1,73 @@
import { expect, vi } from 'vitest';
import { Mock } from 'vitest';
import { getProjectFiles } from '../ProjectService.js';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
vi.mock('fs/promises', () => {
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
const mockStats = {
birthtime: new Date('2020-01-01'),
mtime: new Date('2021-01-01'),
};
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
import { doesProjectExist } from '../projectServiceUtils.js';
// stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => {
return {
readdir: vi.fn().mockResolvedValue(mockFiles),
stat: vi.fn().mockResolvedValue(mockStats),
switchDb: vi.fn(),
};
});
describe('getProjectFiles test', () => {
it('should return a list of project .json files', async () => {
const { readdir, stat } = await import('fs/promises');
vi.mock('../../app-state-service/AppStateService.js', () => ({
isLastLoadedProject: vi.fn(),
}));
const result = await getProjectFiles();
vi.mock('../projectServiceUtils.js', () => ({
doesProjectExist: vi.fn(),
getPathToProject: vi.fn(),
}));
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
filename: file,
updatedAt: new Date('2021-01-01').toISOString(),
}));
/**
* tests only assert errors since the
* controller depend on these to send the right responses
*/
describe('deleteProjectFile', () => {
it('throws an error if trying to delete the currently loaded project', async () => {
(isLastLoadedProject as Mock).mockResolvedValue(true);
await expect(deleteProjectFile('loadedProject')).rejects.toThrow('Cannot delete currently loaded project');
});
expect(result).toEqual(expectedFiles);
expect(readdir).toHaveBeenCalled();
expect(stat).toHaveBeenCalledTimes(expectedFiles.length);
it('throws an error if the project file does not exist', async () => {
(isLastLoadedProject as Mock).mockResolvedValue(false);
(doesProjectExist as Mock).mockReturnValue(null);
await expect(deleteProjectFile('nonexistentProject')).rejects.toThrow('Project file not found');
});
});
describe('duplicateProjectFile', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found');
});
it('throws an error if new file name is already a project', () => {
// current project exists
(doesProjectExist as Mock).mockReturnValueOnce('thisoneexists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
});
});
describe('renameProjectFile', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(renameProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found');
});
it('throws an error if new file name is already a project', async () => {
// current project exists
(doesProjectExist as Mock).mockReturnValueOnce('this one exists');
// new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject');
expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow(
'Project file with name existingproject already exists',
);
});
});
@@ -0,0 +1,33 @@
import { expect, vi } from 'vitest';
import { getProjectFiles } from '../projectServiceUtils.js';
vi.mock('fs/promises', () => {
const mockFiles = ['file1.json', 'file2.json', 'file3.json', 'document.txt', 'image.png'];
const mockStats = {
birthtime: new Date('2020-01-01'),
mtime: new Date('2021-01-01'),
};
return {
readdir: vi.fn().mockResolvedValue(mockFiles),
stat: vi.fn().mockResolvedValue(mockStats),
};
});
describe('getProjectFiles test', () => {
it('should return a list of project .json files', async () => {
const { readdir, stat } = await import('fs/promises');
const result = await getProjectFiles();
const expectedFiles = ['file1', 'file2', 'file3'].map((file) => ({
filename: file,
updatedAt: new Date('2021-01-01').toISOString(),
}));
expect(result).toEqual(expectedFiles);
expect(readdir).toHaveBeenCalled();
expect(stat).toHaveBeenCalledTimes(expectedFiles.length);
});
});
@@ -1,31 +0,0 @@
import { readFileSync } from 'fs';
import { extname } from 'path';
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
* @param files
* @returns
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
export function parseProjectFile(filePath: string): object {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = readFileSync(filePath, 'utf-8');
const uploadedJson = JSON.parse(rawdata);
// at this point, we think this is a DatabaseModel
// verify by looking for the required fields
if (uploadedJson?.settings?.app !== 'ontime') {
throw new Error('Not a ontime project file');
}
return uploadedJson;
}
@@ -0,0 +1,106 @@
import { DatabaseModel, MaybeString, ProjectFile } from 'ontime-types';
import { existsSync } from 'fs';
import { copyFile, readFile, rename, stat } from 'fs/promises';
import { extname, join } from 'path';
import { resolveCorruptDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import { getFilesFromFolder, removeFileExtension } from '../../utils/fileManagement.js';
/**
* Handles the upload of a new project file
* @param filePath
* @param name
*/
export async function handleUploaded(filePath: string, name: string) {
const newFilePath = join(resolveProjectsDirectory, name);
await rename(filePath, newFilePath);
}
/**
* Asynchronously retrieves and returns an array of project files from the 'uploads' folder.
* Each file in the 'uploads' folder is checked, and only those with a '.json' extension are processed.
* For each qualifying file, its metadata is retrieved, including filename, creation time, and last modification time.
*
* @returns {Promise<Array<ProjectFile>>} A promise that resolves to an array of ProjectFile objects,
* each representing a file in the 'uploads' folder with its metadata.
* The metadata includes the filename, creation or overwriting time (updatedAt)
*
* @throws {Error} Throws an error if there is an issue in reading the directory or fetching file statistics.
*/
export async function getProjectFiles(): Promise<ProjectFile[]> {
const allFiles = await getFilesFromFolder(resolveProjectsDirectory);
const filteredFiles = filterProjectFiles(allFiles);
const projectFiles: ProjectFile[] = [];
for (const file of filteredFiles) {
const filePath = join(resolveProjectsDirectory, file);
const stats = await stat(filePath);
projectFiles.push({
filename: removeFileExtension(file),
updatedAt: stats.mtime.toISOString(),
});
}
return projectFiles;
}
/**
* Checks whether a project of a given name exists
* @param name
*/
export function doesProjectExist(name: string): MaybeString {
const projectFilePath = getPathToProject(name);
if (existsSync(projectFilePath)) {
return projectFilePath;
}
return null;
}
/**
* Returns the absolute path to a project file
*/
export function getPathToProject(name: string): string {
return join(resolveProjectsDirectory, name);
}
/**
* Makes a copy of a given project to the corrupted directory
*/
export async function copyCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return copyFile(filePath, newPath);
}
/**
* Moves a file permanently to the corrupted directory
*/
export async function moveCorruptFile(filePath: string, name: string): Promise<void> {
const newPath = join(resolveCorruptDirectory, name);
return rename(filePath, newPath);
}
/**
* Given an array of file names, filters out any files that do not have a '.json' extension.
* We assume these are project files
*/
export function filterProjectFiles(files: Array<string>): Array<string> {
return files.filter((file) => {
const ext = extname(file).toLowerCase();
return ext === '.json';
});
}
/**
* Parses a project file and returns the JSON object
* @throws It will throw an error if it cannot read or parse the file
*/
export async function parseJsonFile(filePath: string): Promise<Partial<DatabaseModel>> {
if (!filePath.endsWith('.json')) {
throw new Error('Invalid file type');
}
const rawdata = await readFile(filePath, 'utf-8');
return JSON.parse(rawdata);
}
@@ -245,7 +245,7 @@ function notifyChanges(options: { timer?: boolean | string[]; external?: boolean
if (options.external) {
// advice socket subscribers of change
sendRefetch();
sendRefetch(Array.isArray(options.timer) ? options.timer : undefined);
}
}
@@ -27,6 +27,19 @@ import {
customFieldChangelog,
} from '../rundownCache.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('generate()', () => {
it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [
@@ -849,23 +862,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
describe('custom fields', () => {
describe('createCustomField()', () => {
beforeEach(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
DataProvider: {
...vi.fn().mockImplementation(() => {
return {};
}),
getCustomFields: vi.fn().mockReturnValue({}),
setCustomFields: vi.fn().mockImplementation((newData) => {
return newData;
}),
persist: vi.fn().mockReturnValue({}),
},
};
});
});
it('creates a field from given parameters', async () => {
const expected = {
lighting: {
@@ -0,0 +1,39 @@
import { OntimeRundown } from 'ontime-types';
import { getPaginated } from '../rundownUtils.js';
describe('getPaginated', () => {
// mock cache so we dont run data functions
beforeAll(() => {
vi.mock('../rundownCache.js', () => ({}));
});
// @ts-expect-error -- we know this is not correct, but good enough for the test
const getData = () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as OntimeRundown;
it('should return the correct paginated rundown', () => {
const offset = 0;
const limit = 1;
const result = getPaginated(offset, limit, getData);
expect(result.rundown).toHaveLength(1);
expect(result.total).toBe(10);
});
it('should handle overflows', () => {
const offset = 0;
const limit = 20;
const result = getPaginated(offset, limit, getData);
expect(result.rundown).toHaveLength(10);
expect(result.total).toBe(10);
});
it('should handle out of range', () => {
const offset = 11;
const limit = Infinity;
const result = getPaginated(offset, limit, getData);
expect(result.rundown).toHaveLength(0);
expect(result.total).toBe(10);
});
});
@@ -11,7 +11,7 @@ import {
} from 'ontime-types';
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { getTotalDuration } from '../timerUtils.js';
import { apply } from './delayUtils.js';
@@ -59,8 +59,8 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
persistedRundown = structuredClone(initialRundown) as OntimeRundown;
persistedCustomFields = structuredClone(customFields);
generate();
await DataProvider.setRundown(persistedRundown);
await DataProvider.setCustomFields(customFields);
await getDataProvider().setRundown(persistedRundown);
await getDataProvider().setCustomFields(customFields);
}
/**
@@ -248,8 +248,8 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
});
// defer writing to the database
setImmediate(() => {
DataProvider.setRundown(persistedRundown);
setImmediate(async () => {
await getDataProvider().setRundown(persistedRundown);
});
return { newEvent, newRundown, didMutate };
@@ -413,9 +413,9 @@ function invalidateIfUsed(label: CustomFieldLabel) {
}
// ... and schedule a cache update
// schedule a non priority cache update
setImmediate(() => {
setImmediate(async () => {
generate();
DataProvider.setRundown(persistedRundown);
await getDataProvider().setRundown(persistedRundown);
});
}
@@ -424,8 +424,8 @@ function invalidateIfUsed(label: CustomFieldLabel) {
* @param persistedCustomFields
*/
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
setImmediate(() => {
DataProvider.setCustomFields(persistedCustomFields);
setImmediate(async () => {
await getDataProvider().setCustomFields(persistedCustomFields);
});
}
@@ -1,4 +1,4 @@
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached } from 'ontime-types';
import { OntimeEvent, OntimeRundown, isOntimeEvent, RundownCached, OntimeRundownEntry } from 'ontime-types';
import * as cache from './rundownCache.js';
@@ -53,9 +53,9 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
* @param {string} eventId
* @return {object | undefined}
*/
export function getEventWithId(eventId: string): OntimeEvent | undefined {
const timedEvents = getTimedEvents();
return timedEvents.find((event) => event.id === eventId);
export function getEventWithId(eventId: string): OntimeRundownEntry | undefined {
const rundown = getRundown();
return rundown.find((event) => event.id === eventId);
}
/**
@@ -117,3 +117,19 @@ export function findNext(currentEventId?: string): OntimeEvent | null {
const nextEvent = timedEvents.at(newIndex);
return nextEvent ?? null;
}
/**
* Returns a paginated rundown
* Exposes a getter function for the rundown for testing
*/
export function getPaginated(
offset: number,
limit: number,
source = getRundown,
): { rundown: OntimeRundownEntry[]; total: number } {
const rundown = source();
return {
rundown: rundown.slice(Math.min(offset, rundown.length), Math.min(offset + limit, rundown.length)),
total: rundown.length,
};
}
@@ -1,5 +1,6 @@
import {
EndAction,
isOntimeEvent,
LogOrigin,
MaybeNumber,
OntimeEvent,
@@ -225,6 +226,9 @@ class RuntimeService {
}
// load stuff again, but keep running if our events still exist
const eventNow = getEventWithId(state.eventNow.id);
if (!isOntimeEvent(eventNow)) {
return;
}
const onlyChangedNow = affectedIds?.length === 1 && affectedIds.at(0) === eventNow.id;
if (onlyChangedNow) {
runtimeState.reload(eventNow);
@@ -272,7 +276,7 @@ class RuntimeService {
*/
startById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event) {
if (!event || !isOntimeEvent(event)) {
return false;
}
const loaded = this.loadEvent(event);
@@ -323,7 +327,7 @@ class RuntimeService {
*/
loadById(eventId: string): boolean {
const event = getEventWithId(eventId);
if (!event) {
if (!event || !isOntimeEvent(event)) {
return false;
}
return this.loadEvent(event);
@@ -476,7 +480,13 @@ class RuntimeService {
reload() {
const state = runtimeState.getState();
if (state.eventNow) {
runtimeState.reload();
const eventId = runtimeState.reload();
if (eventId) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${eventId}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
}
}
@@ -523,7 +533,7 @@ class RuntimeService {
// 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
const event = getEventWithId(selectedEventId);
if (!event) {
if (!event || !isOntimeEvent(event)) {
return;
}
@@ -16,7 +16,7 @@ import { ensureDirectory } from '../../utils/fileManagement.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js';
import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js';
import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js';
import { parseRundown } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js';
const sheetScope = 'https://www.googleapis.com/auth/spreadsheets';
@@ -31,7 +31,7 @@ let currentAuthCode: MaybeString = null;
let currentSheetId: MaybeString = null;
let pollInterval: NodeJS.Timer | null = null;
let pollInterval: NodeJS.Timeout | null = null;
let cleanupTimeout: NodeJS.Timeout | null = null;
function reset() {
@@ -366,10 +366,9 @@ export async function download(
}
const dataFromSheet = parseExcel(googleResponse.data.values, options);
const rundown = parseRundown(dataFromSheet);
const { customFields, rundown } = parseRundown(dataFromSheet);
if (rundown.length < 1) {
throw new Error('Sheet: Could not find data to import in the worksheet');
}
const customFields = parseCustomFields(dataFromSheet);
return { rundown, customFields };
}
+2 -2
View File
@@ -299,10 +299,10 @@ export const updateRoll = (state: RuntimeState) => {
* Positive offset is time ahead
* Negative offset is time delayed
*/
export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
export function getRuntimeOffset(state: RuntimeState): number {
// nothing to calculate if there are no loaded events or if we havent started
if (state.eventNow === null || state.runtime.actualStart === null) {
return null;
return 0;
}
const { clock } = state;
+4
View File
@@ -1,5 +1,9 @@
export const config = {
appState: 'app-state.json',
corrupt: 'corrupt files',
crash: 'crash logs',
demoProject: 'demo project.json',
newProject: 'new project.json',
database: {
testdb: 'test-db',
directory: 'db',
+19 -35
View File
@@ -1,6 +1,5 @@
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { readFileSync, writeFileSync } from 'fs';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -56,10 +55,10 @@ const currentDir = dirname(__dirname);
// locally we are in src/setup, in the production build, this is a single file at src
export const srcDirectory = isProduction ? currentDir : join(currentDir, '../');
// resolve path to external
// TODO: simplify logic
// resolve path to client
const productionPath = join(srcDirectory, 'client/');
const devPath = join(srcDirectory, '../../client/build/');
export const resolvedPath = (): string => {
if (isTest) {
return devPath;
@@ -70,40 +69,21 @@ export const resolvedPath = (): string => {
return devPath;
};
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(srcDirectory, 'external');
// resolve public directory
export const resolvePublicDirectoy = getAppDataPath();
ensureDirectory(resolvePublicDirectoy);
const testDbStartDirectory = isTest ? '../' : resolvePublicDirectoy;
export const externalsStartDirectory = isProduction ? resolvePublicDirectoy : join(srcDirectory, 'external');
// TODO: we only need one when they are all in the same folder
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : srcDirectory, 'external');
export const resolveExternalsDirectory = join(isProduction ? resolvePublicDirectoy : srcDirectory, 'external');
// project files
export const appStatePath = join(getAppDataPath(), config.appState);
export const uploadsFolderPath = join(getAppDataPath(), config.uploads);
const ensureAppState = () => {
ensureDirectory(getAppDataPath());
writeFileSync(appStatePath, JSON.stringify({ lastLoadedProject: 'db.json' }));
};
const getLastLoadedProject = () => {
try {
const appState = JSON.parse(readFileSync(appStatePath, 'utf8'));
if (!appState.lastLoadedProject) {
ensureAppState();
}
return appState.lastLoadedProject;
} catch {
if (!isTest) {
ensureAppState();
}
}
};
const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject();
export const appStatePath = join(resolvePublicDirectoy, config.appState);
export const uploadsFolderPath = join(resolvePublicDirectoy, config.uploads);
// path to public db
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : config.projects);
export const resolveDbName = lastLoadedProject ? lastLoadedProject : config.database.filename;
export const resolveDbPath = join(resolveDbDirectory, resolveDbName);
export const pathToStartDb = isTest
? join(srcDirectory, '..', config.database.testdb, config.database.filename)
@@ -125,18 +105,22 @@ export const resolveDemoPath = config.demo.filename.map((file) => {
return join(resolveDemoDirectory, file);
});
// path to demo project
export const pathToStartDemo = config.demo.filename.map((file) => {
return join(srcDirectory, '/external/demo/', file);
});
// path to restore file
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
export const resolveRestoreFile = join(resolvePublicDirectoy, config.restoreFile);
// path to sheets folder
export const resolveSheetsDirectory = join(getAppDataPath(), config.sheets.directory);
export const resolveSheetsDirectory = join(resolvePublicDirectoy, config.sheets.directory);
// path to crash reports
export const resolveCrashReportDirectory = getAppDataPath();
export const resolveCrashReportDirectory = join(resolvePublicDirectoy, config.crash);
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export const resolveProjectsDirectory = join(resolvePublicDirectoy, config.projects);
// path to corrupt files
export const resolveCorruptDirectory = join(resolvePublicDirectoy, config.corrupt);
-100
View File
@@ -1,100 +0,0 @@
import { DatabaseModel } from 'ontime-types';
import { Low } from 'lowdb';
import { JSONFile } from 'lowdb/node';
import { copyFileSync, existsSync } from 'fs';
import { join } from 'path';
import { ensureDirectory } from '../utils/fileManagement.js';
import { dbModel } from '../models/dataModel.js';
import { pathToStartDb, resolveDbDirectory, resolveDbName } from './index.js';
import { parseProjectFile } from '../services/project-service/projectFileUtils.js';
import { parseJson } from '../utils/parser.js';
/**
* @description ensures directories exist and populates database
* @return {string} - path to db file
*/
const populateDb = (directory: string, filename: string): string => {
ensureDirectory(directory);
let dbPath = join(directory, filename);
// if everything goes well, the DB in disk is the one loaded
// if dbInDisk doesn't exist we want to use startup db
if (!existsSync(dbPath)) {
try {
const dbDirectory = resolveDbDirectory;
const startDbName = pathToStartDb.split('/').pop();
if (!startDbName) {
throw new Error('Invalid path to start database');
}
const newFileDirectory = join(dbDirectory, startDbName);
copyFileSync(pathToStartDb, newFileDirectory);
dbPath = newFileDirectory;
} catch (_) {
/* we do not handle this */
}
}
return dbPath;
};
/**
* @description parses a json file to the adapter
* It will create an empty file from the model if the parsing fails
*/
const parseDatabase = async (fileToRead: string, adapterToUse: Low<DatabaseModel>) => {
try {
// this will throw if file is not valid
parseProjectFile(fileToRead);
await adapterToUse.read();
} catch (error) {
adapterToUse.data = dbModel;
}
return parseJson(adapterToUse.data);
};
/**
* @description loads ontime db
*/
async function loadDb(directory: string, filename: string) {
const dbInDisk = populateDb(directory, filename);
const adapter = new JSONFile<DatabaseModel>(dbInDisk);
const db = new Low(adapter, dbModel);
const data = await parseDatabase(dbInDisk, db);
db.data = data;
await db.write();
return { db, data };
}
export let db = {} as Low<DatabaseModel>;
export let data = {} as DatabaseModel;
export const dbLoadingProcess = loadDb(resolveDbDirectory, resolveDbName);
/**
* Initialises database at known location
*/
const init = async () => {
const dbProvider = await dbLoadingProcess;
db = dbProvider.db;
data = dbProvider.data;
};
/**
* Allows to switch the database to a new file
*/
export const switchDb = async (newFileName: string) => {
const { db: newDb, data: newData } = await loadDb(resolveDbDirectory, newFileName);
db = newDb;
data = newData;
};
init();
@@ -45,6 +45,19 @@ const makeMockState = (patch: RuntimeState): RuntimeState => {
return deepmerge(mockState, patch);
};
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setCustomFields: vi.fn().mockImplementation((newData) => newData),
setRundown: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('mutation on runtimeState', () => {
beforeEach(() => {
clear();
@@ -201,7 +214,7 @@ describe('mutation on runtimeState', () => {
stop();
newState = getState();
expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.offset).toBeNull();
expect(newState.runtime.offset).toBe(0);
expect(newState.runtime.expectedEnd).toBeNull();
});
+5 -3
View File
@@ -19,7 +19,7 @@ import { timerConfig } from '../config/config.js';
const initialRuntime: Runtime = {
selectedEventIndex: null,
numEvents: 0,
offset: null,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
@@ -82,7 +82,7 @@ export function clear() {
runtimeState.eventNext = null;
runtimeState.publicEventNext = null;
runtimeState.runtime.offset = null;
runtimeState.runtime.offset = 0;
runtimeState.runtime.actualStart = null;
runtimeState.runtime.expectedEnd = null;
runtimeState.runtime.selectedEventIndex = null;
@@ -236,6 +236,7 @@ export function resume(restorePoint: RestorePoint, event: OntimeEvent, rundown:
* @param {OntimeEvent} event only passed if we are changing the data if a playing timer
*/
export function reload(event?: OntimeEvent) {
// we only pass an event for hot reloading, ie: the event has changed
if (event) {
runtimeState.eventNow = event;
@@ -243,7 +244,7 @@ export function reload(event?: OntimeEvent) {
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
runtimeState.timer.current = getCurrent(runtimeState);
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
return;
return runtimeState.eventNow.id;
}
runtimeState.timer.playback = Playback.Armed;
@@ -257,6 +258,7 @@ export function reload(event?: OntimeEvent) {
runtimeState.timer.addedTime = 0;
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
return runtimeState.eventNow.id;
}
/**
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest';
import { ensureJsonExtension } from '../fileManagement.js';
import { describe, it, expect, Mock } from 'vitest';
import * as fs from 'fs';
import { appendToName, ensureJsonExtension, generateUniqueFileName } from '../fileManagement.js';
// Mock fs.existsSync to control the test environment
vi.mock('fs', () => ({
existsSync: vi.fn(),
}));
describe('ensureJsonExtension', () => {
it('should add .json to a filename without an extension', () => {
@@ -26,3 +33,60 @@ describe('ensureJsonExtension', () => {
expect(result).toBe('my.test.file.json');
});
});
describe('appendToName', () => {
it('appends a given string to a file name', () => {
const filename = 'file.json';
const append = '(recovered)';
const result = appendToName(filename, append);
expect(result).toBe('file (recovered).json');
});
it('handles paths', () => {
const path = '/Users/carlos/Library/Application Support/Ontime/projects/file.json';
const append = '(recovered)';
const result = appendToName(path, append);
expect(result).toBe('/Users/carlos/Library/Application Support/Ontime/projects/file (recovered).json');
});
it('handles multiple . in string', () => {
const path = 'strange.file.name.json';
const append = '(recovered)';
const result = appendToName(path, append);
expect(result).toBe('strange.file.name (recovered).json');
});
});
describe('generateUniqueFileName', () => {
const directory = '/test/directory';
const filename = 'testFile.txt';
const baseName = 'testFile';
const extension = '.txt';
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
it('should return original file name if there is no conflict', () => {
(fs.existsSync as Mock).mockReturnValue(false);
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(filename);
});
it('should append a counter to the filename if a conflict exists', () => {
// Mock the first call to return true (file exists), then false
(fs.existsSync as Mock).mockReturnValueOnce(true).mockReturnValueOnce(false);
const expectedFilename = `${baseName} (1)${extension}`;
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(expectedFilename);
});
it('should increment the counter for each conflict until a unique filename is found', () => {
// Mock the first two calls to return true (file exists), then false
(fs.existsSync as Mock).mockReturnValueOnce(true).mockReturnValueOnce(true).mockReturnValueOnce(false);
const expectedFilename = `${baseName} (2)${extension}`;
const uniqueFilename = generateUniqueFileName(directory, filename);
expect(uniqueFilename).toBe(expectedFilename);
});
});
+100 -77
View File
@@ -16,12 +16,31 @@ import {
import { dbModel } from '../../models/dataModel.js';
import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js';
import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
import * as cache from '../../services/rundown-service/rundownCache.js';
const requiredSettings = {
app: 'ontime',
version: 'any',
};
// mock data provider
beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => {
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
setRundown: vi.fn().mockImplementation((newData) => newData),
setCustomFields: vi.fn().mockImplementation((newData) => newData),
};
}),
};
});
});
describe('test json parser with valid def', () => {
const testData: Partial<DatabaseModel> = {
rundown: [
@@ -182,19 +201,15 @@ describe('test json parser with valid def', () => {
viewSettings: {} as ViewSettings,
};
let parseResponse;
beforeEach(async () => {
parseResponse = await parseJson(testData);
});
const { data } = parseDatabaseModel(testData);
it('has 7 events', () => {
const length = parseResponse?.rundown.length;
const length = data.rundown.length;
expect(length).toBe(7);
});
it('first event is as a match', () => {
const first = parseResponse?.rundown[0];
const first = data.rundown[0];
const expected = {
title: 'Guest Welcoming',
type: 'event',
@@ -204,7 +219,7 @@ describe('test json parser with valid def', () => {
});
it('second event is as a match', () => {
const second = parseResponse?.rundown[1];
const second = data.rundown[1];
const expected = {
title: 'Good Morning',
type: 'event',
@@ -213,40 +228,36 @@ describe('test json parser with valid def', () => {
expect(second).toMatchObject(expected);
});
it('third event end action is set as the default value', () => {
const third = parseResponse?.rundown[2];
expect(third.endAction).toStrictEqual(EndAction.None);
const third = data.rundown[2];
expect((third as OntimeEvent).endAction).toStrictEqual(EndAction.None);
});
it('fourth event timer type is set as the default value', () => {
const fourth = parseResponse?.rundown[3];
expect(fourth.timerType).toStrictEqual(TimerType.Clock);
const fourth = data.rundown[3];
expect((fourth as OntimeEvent).timerType).toStrictEqual(TimerType.Clock);
});
it('loaded event settings', () => {
const eventTitle = parseResponse?.project?.title;
const eventTitle = data.project.title;
expect(eventTitle).toBe('This is a test definition');
});
it('endMessage to exist but be empty', () => {
const endMessage = parseResponse?.viewSettings?.endMessage;
const endMessage = data.viewSettings.endMessage;
expect(endMessage).toBeDefined();
expect(endMessage).toBe('');
});
it('settings are for right app and version', () => {
const settings = parseResponse?.settings;
const settings = data.settings;
expect(settings.app).toBe('ontime');
expect(settings.version).toEqual(expect.any(String));
});
it('missing settings', () => {
const settings = parseResponse?.settings;
expect(settings.osc_port).toBeUndefined();
});
});
describe('test parser edge cases', () => {
it('stringifies necessary values', async () => {
it('stringifies necessary values', () => {
const testData = {
settings: { ...requiredSettings },
rundown: [
{
cue: 101,
@@ -260,13 +271,14 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parseResponse = await parseJson(testData);
expect(typeof (parseResponse.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (parseResponse.rundown[1] as OntimeEvent).cue).toBe('string');
const { data } = parseDatabaseModel(testData);
expect(typeof (data.rundown[0] as OntimeEvent).cue).toBe('string');
expect(typeof (data.rundown[1] as OntimeEvent).cue).toBe('string');
});
it('generates missing ids', async () => {
it('generates missing ids', () => {
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event',
@@ -276,13 +288,14 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parseResponse = await parseJson(testData);
expect(parseResponse.rundown[0].id).toBeDefined();
const { data } = parseDatabaseModel(testData);
expect(data.rundown[0].id).toBeDefined();
});
it('detects duplicate Ids', async () => {
it('detects duplicate Ids', () => {
console.log = vi.fn();
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event 1',
@@ -298,14 +311,15 @@ describe('test parser edge cases', () => {
};
//@ts-expect-error -- we know this is wrong, testing imports outside domain
const parseResponse = await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: ID collision on import, skipping');
expect(parseResponse?.rundown.length).toBe(1);
const { data, errors } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(1);
expect(errors.length).toBe(7);
});
it('handles incomplete datasets', async () => {
it('handles incomplete datasets', () => {
console.log = vi.fn();
const testData = {
settings: { ...requiredSettings },
rundown: [
{
title: 'Test Event 1',
@@ -319,11 +333,11 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parseResponse = await parseJson(testData);
expect(parseResponse?.rundown.length).toBe(0);
const { data } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(0);
});
it('skips unknown app and version settings', async () => {
it('skips unknown app and version settings', () => {
console.log = vi.fn();
const testData = {
settings: {
@@ -332,13 +346,12 @@ describe('test parser edge cases', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
await parseJson(testData);
expect(console.log).toHaveBeenCalledWith('ERROR: unable to parse settings, missing app or version');
expect(() => parseDatabaseModel(testData)).toThrow();
});
});
describe('test corrupt data', () => {
it('handles some empty events', async () => {
it('handles some empty events', () => {
const emptyEvents = {
rundown: [
{},
@@ -376,11 +389,11 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsedDef = await parseJson(emptyEvents);
expect(parsedDef.rundown.length).toBe(2);
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(2);
});
it('handles all empty events', async () => {
it('handles all empty events', () => {
const emptyEvents = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
project: {
@@ -401,11 +414,11 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsedDef = await parseJson(emptyEvents);
expect(parsedDef.rundown.length).toBe(0);
const { data } = parseDatabaseModel(emptyEvents);
expect(data.rundown.length).toBe(0);
});
it('handles missing project data', async () => {
it('handles missing project data', () => {
const emptyProjectData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
project: {},
@@ -419,11 +432,11 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsedDef = await parseJson(emptyProjectData);
const { data: parsedDef } = parseDatabaseModel(emptyProjectData);
expect(parsedDef.project).toStrictEqual(dbModel.project);
});
it('handles missing settings', async () => {
it('handles missing settings', () => {
const missingSettings = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
@@ -434,16 +447,13 @@ describe('test corrupt data', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsedDef = await parseJson(missingSettings);
expect(parsedDef.settings).toStrictEqual(dbModel.settings);
const { data } = parseDatabaseModel(missingSettings);
expect(data.settings).toStrictEqual(dbModel.settings);
});
it('fails with invalid JSON', async () => {
const invalidJSON = 'some random dataset';
it('fails with invalid JSON', () => {
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsedDef = await parseJson(invalidJSON);
expect(parsedDef).toBeNull();
expect(() => parseDatabaseModel('some random dataset')).toThrow();
});
});
@@ -485,6 +495,9 @@ describe('test event validator', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event, 'not-used');
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
expect(typeof validated.note).toEqual('string');
});
@@ -496,6 +509,9 @@ describe('test event validator', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
assertType<number>(validated.timeStart);
assertType<number>(validated.timeEnd);
assertType<number>(validated.duration);
@@ -510,6 +526,9 @@ describe('test event validator', () => {
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = createEvent(event);
if (validated === null) {
throw new Error('unexpected value');
}
expect(typeof validated.title).toEqual('string');
});
});
@@ -588,7 +607,7 @@ describe('test views import', () => {
});
describe('test import of v2 datamodel', () => {
it('ignores deprecated fields and generates new ones', async () => {
it('ignores deprecated fields and generates new ones', () => {
const v2ProjectFile = {
rundown: [
{ type: SupportedEvent.Block, title: 'block-title', id: 'block-id' },
@@ -662,7 +681,7 @@ describe('test import of v2 datamodel', () => {
},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const parsed = await parseJson(v2ProjectFile);
const { data: parsed, _errors } = parseDatabaseModel(v2ProjectFile);
expect(parsed.rundown.length).toBe(3);
expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block });
expect(parsed.rundown[0]).toEqual(
@@ -787,7 +806,7 @@ describe('getCustomFieldData()', () => {
});
describe('parseExcel()', () => {
it('parses the example file', async () => {
it('parses the example file', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
@@ -980,7 +999,7 @@ describe('parseExcel()', () => {
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
it('parses a file without custom fields', async () => {
it('parses a file without custom fields', () => {
const testdata = [
['Ontime ┬À Schedule Template'],
[],
@@ -1293,7 +1312,7 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testdata, importMap);
expect(result.rundown.length).toBe(2);
expect(result.rundown.at(0).type).toBe(SupportedEvent.Block);
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Block);
});
it('imports as events if there is no timer type column', () => {
@@ -1363,9 +1382,9 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testdata, importMap);
expect(result.rundown.length).toBe(2);
expect(result.rundown.at(0).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown);
expect(result.rundown.at(1).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event);
expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown);
});
@@ -1401,16 +1420,16 @@ describe('parseExcel()', () => {
custom: {},
};
const result = parseExcel(testData, importMap);
const rundown = parseRundown(result);
const { rundown } = parseRundown(result);
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
expect(events.at(0).timeStart).toEqual(16200000);
expect(events.at(1).timeStart).toEqual(35100000);
expect(events.at(2).timeStart).toEqual(59400000);
expect(events.at(3).timeStart).toEqual(78300000);
expect(events.at(4).timeStart).toEqual(16200000);
expect(events.at(5).timeStart).toEqual(35100000);
expect(events.at(6).timeStart).toEqual(59400000);
expect(events.at(7).timeStart).toEqual(78300000);
expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000);
expect((events.at(1) as OntimeEvent).timeStart).toEqual(35100000);
expect((events.at(2) as OntimeEvent).timeStart).toEqual(59400000);
expect((events.at(3) as OntimeEvent).timeStart).toEqual(78300000);
expect((events.at(4) as OntimeEvent).timeStart).toEqual(16200000);
expect((events.at(5) as OntimeEvent).timeStart).toEqual(35100000);
expect((events.at(6) as OntimeEvent).timeStart).toEqual(59400000);
expect((events.at(7) as OntimeEvent).timeStart).toEqual(78300000);
});
it('handle leading and trailing whitespace', () => {
@@ -1438,12 +1457,12 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testData, importMap);
const rundown = parseRundown(result);
const { rundown } = parseRundown(result);
const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
expect(events.at(0).timeStart).toEqual(16200000); //<--leading white space in MAP
expect(events.at(0).timeEnd).toEqual(16200000); //<--trailing white space in MAP
expect(events.at(0).title).toEqual('A song from the hearth'); //<--leading white space in Excel data
expect(events.at(0).colour).toEqual('#F00'); //<--trailing white space in Excel data
expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); //<--leading white space in MAP
expect((events.at(0) as OntimeEvent).timeEnd).toEqual(16200000); //<--trailing white space in MAP
expect((events.at(0) as OntimeEvent).title).toEqual('A song from the hearth'); //<--leading white space in Excel data
expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data
});
it('link start', () => {
@@ -1490,9 +1509,9 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testData, importMap);
const initialRundown = parseRundown(result);
const parseResult = parseRundown(result);
cache.init(initialRundown, {});
cache.init(parseResult.rundown, parseResult.customFields);
const { rundown, order } = cache.get();
const firstId = order.at(0); // A
@@ -1502,6 +1521,10 @@ describe('parseExcel()', () => {
const fifhtId = order.at(4); // Block
const sixthId = order.at(5); // G
if (!firstId || !secondId || !thirdId || !fourthId || !fifhtId || !sixthId) {
throw new Error('Unexpected value');
}
expect((rundown[firstId] as OntimeEvent).timeStart).toEqual(16200000);
expect((rundown[secondId] as OntimeEvent).timeStart).toEqual((rundown[firstId] as OntimeEvent).timeEnd);

Some files were not shown because too many files have changed in this diff Show More