mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ef0b81743 | |||
| 5a5450d7ef | |||
| 6f1f10ccff | |||
| 5ff7861968 | |||
| f1a1c358bc | |||
| efd19353e9 | |||
| a29a493678 | |||
| 27b179276d | |||
| 155cf48a17 | |||
| 358ad79ae4 | |||
| c1fcdf7065 | |||
| 1fe58e21be | |||
| 3c0e7ba4d5 | |||
| e8f159d894 | |||
| 168d7103b1 | |||
| 63d7250a70 | |||
| 88f21e2186 | |||
| d9a18b0c42 | |||
| df588b8845 | |||
| 5be504ce8f | |||
| 6268e327b9 | |||
| 1c81b3a23c | |||
| d28895ce9c | |||
| 1a23287863 | |||
| 0b3ba65df3 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosResponse } from 'axios';
|
||||||
import { Settings } from 'ontime-types';
|
import { PortInfo, Settings } from 'ontime-types';
|
||||||
|
|
||||||
import { apiEntryUrl } from './constants';
|
import { apiEntryUrl } from './constants';
|
||||||
import type { RequestOptions } from './requestOptions';
|
import type { RequestOptions } from './requestOptions';
|
||||||
@@ -27,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
|
|||||||
export async function postShowWelcomeDialog(show: boolean) {
|
export async function postShowWelcomeDialog(show: boolean) {
|
||||||
axios.post(`${settingsPath}/welcomedialog`, { show });
|
axios.post(`${settingsPath}/welcomedialog`, { show });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to retrieve server port
|
||||||
|
*/
|
||||||
|
export async function getServerPort(): Promise<PortInfo> {
|
||||||
|
const res = await axios.get(`${settingsPath}/serverport`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to set server port
|
||||||
|
*/
|
||||||
|
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
|
||||||
|
return axios.post(`${settingsPath}/serverport`, { serverPort });
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ $thumb-color-hover: $white-60;
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar {
|
.scrollbar {
|
||||||
|
|||||||
@@ -202,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
|||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
groupExpectedEnd: state.offset.expectedGroupEnd,
|
groupExpectedEnd: state.offset.expectedGroupEnd,
|
||||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
// we can force these numbers to 0 for this use case to avoid null checks
|
||||||
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
actualGroupStart: state.rundown.actualGroupStart ?? 0,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||||
clock: state.clock,
|
clock: state.clock,
|
||||||
mode: state.offset.mode,
|
mode: state.offset.mode,
|
||||||
// we can force these numbers to 0 fo this use case to avoid null checks
|
// we can force these numbers to 0 for this use case to avoid null checks
|
||||||
actualStart: state.rundown.actualStart ?? 0,
|
actualStart: state.rundown.actualStart ?? 0,
|
||||||
plannedStart: state.rundown.plannedStart ?? 0,
|
plannedStart: state.rundown.plannedStart ?? 0,
|
||||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
currentDay: state.rundown.currentDay ?? 0,
|
||||||
playback: state.timer.playback,
|
playback: state.timer.playback,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
|
|||||||
|
|
||||||
export const ontimePlaceholderSettings: Settings = {
|
export const ontimePlaceholderSettings: Settings = {
|
||||||
version: '4.0.0',
|
version: '4.0.0',
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
|
|||||||
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
|
|||||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('cuesheet presets', () => {
|
||||||
|
const cuesheetPreset: URLPreset[] = [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-4685d6',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
options: {
|
||||||
|
read: 'full',
|
||||||
|
write: '-',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const cuesheetPresetWithoutOptions: URLPreset[] = [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-basic',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-locked',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: 'n=1',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('keeps cuesheet aliases masked when permissions are stored in preset options', () => {
|
||||||
|
const location = resolvePath('/cuesheet-4685d6');
|
||||||
|
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves feature params when redirecting masked cuesheet aliases', () => {
|
||||||
|
const location = resolvePath('/cuesheet-4685d6?n=1&token=123');
|
||||||
|
expect(getRouteFromPreset(location, cuesheetPreset)).toBe('preset/cuesheet-4685d6?n=1&token=123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cuesheet aliases masked even when preset options are absent', () => {
|
||||||
|
const location = resolvePath('/cuesheet-basic');
|
||||||
|
expect(getRouteFromPreset(location, cuesheetPresetWithoutOptions)).toBe('preset/cuesheet-basic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies navigation lock from preset search params when alias is opened', () => {
|
||||||
|
const location = resolvePath('/cuesheet-locked');
|
||||||
|
expect(getRouteFromPreset(location, cuesheetPresetWithNavLock)).toBe('preset/cuesheet-locked?n=1');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generatePathFromPreset()', () => {
|
describe('generatePathFromPreset()', () => {
|
||||||
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
|
|||||||
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?test=b')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('distinguishes preset paths with different lock or token params', () => {
|
||||||
|
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?n=1')).toBeFalsy();
|
||||||
|
expect(arePathsEquivalent('preset/minimal?n=1', 'preset/minimal?n=1')).toBeTruthy();
|
||||||
|
expect(arePathsEquivalent('preset/minimal', 'preset/minimal?token=abc')).toBeFalsy();
|
||||||
|
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1')).toBeFalsy();
|
||||||
|
expect(arePathsEquivalent('preset/minimal?n=1&token=abc', 'preset/minimal?n=1&token=abc')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('considers edge cases for the url sharing feature', () => {
|
it('considers edge cases for the url sharing feature', () => {
|
||||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=b')).toBeFalsy();
|
||||||
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
expect(arePathsEquivalent('timer?test=a&n=1=token=123', 'timer?test=a')).toBeTruthy();
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
|||||||
// NOTE: verify that this resolves correctly in cloud
|
// NOTE: verify that this resolves correctly in cloud
|
||||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||||
const currentURL = getCurrentPath(location);
|
const currentURL = getCurrentPath(location);
|
||||||
const token = new URLSearchParams(location.search).get('token');
|
const locationParams = new URLSearchParams(location.search);
|
||||||
const isLocked = location.search.includes('n=1');
|
const token = locationParams.get('token');
|
||||||
|
|
||||||
for (const preset of urlPresets) {
|
for (const preset of urlPresets) {
|
||||||
if (!preset.enabled) continue;
|
if (!preset.enabled) continue;
|
||||||
|
const presetParams = new URLSearchParams(preset.search);
|
||||||
|
const isLocked = locationParams.get('n') === '1' || presetParams.get('n') === '1';
|
||||||
/**
|
/**
|
||||||
* If the page is a known alias it would be like
|
* If the page is a known alias it would be like
|
||||||
* /preset/{alias} <- locked to a preset
|
* /preset/{alias} <- locked to a preset
|
||||||
@@ -53,7 +55,9 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
|||||||
* we need to compare the saved preset to the current path to see if we need to redirect
|
* we need to compare the saved preset to the current path to see if we need to redirect
|
||||||
*/
|
*/
|
||||||
if (preset.alias === currentURL || preset.target === currentURL) {
|
if (preset.alias === currentURL || preset.target === currentURL) {
|
||||||
const newPath = generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
const newPath = shouldMaskPresetPath(preset)
|
||||||
|
? generateMaskedPathFromPreset(preset.alias, isLocked, token)
|
||||||
|
: generatePathFromPreset(preset.target, preset.search, preset.alias, isLocked, token);
|
||||||
/**
|
/**
|
||||||
* if the current path is equivalent to the new path, we return null
|
* if the current path is equivalent to the new path, we return null
|
||||||
* this means we will not redirect
|
* this means we will not redirect
|
||||||
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
|
|||||||
return `${path.pathname}?${searchParams}`.substring(1);
|
return `${path.pathname}?${searchParams}`.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateMaskedPathFromPreset(alias: string, locked: boolean, token: string | null): string {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
searchParams.set('n', '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
searchParams.set('token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = `preset/${alias}`;
|
||||||
|
const search = searchParams.toString();
|
||||||
|
if (!search) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${path}?${search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldMaskPresetPath(preset: URLPreset): boolean {
|
||||||
|
return preset.target === OntimeView.Cuesheet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility checks if two paths are equivalent
|
* Utility checks if two paths are equivalent
|
||||||
* For preset paths, only compares the path (since params are stored in session)
|
* For preset paths, only compares the path (since params are stored in session)
|
||||||
@@ -123,16 +151,19 @@ export function arePathsEquivalent(currentPath: string, newPath: string): boolea
|
|||||||
const currentUrl = new URL(currentPath, document.location.origin);
|
const currentUrl = new URL(currentPath, document.location.origin);
|
||||||
const newUrl = new URL(newPath, document.location.origin);
|
const newUrl = new URL(newPath, document.location.origin);
|
||||||
|
|
||||||
// For preset paths, only compare the path
|
|
||||||
if (currentUrl.pathname.startsWith('/preset/') || newUrl.pathname.startsWith('/preset/')) {
|
|
||||||
return currentUrl.pathname === newUrl.pathname;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For regular paths, compare path and search params (ignoring token)
|
|
||||||
if (currentUrl.pathname !== newUrl.pathname) {
|
if (currentUrl.pathname !== newUrl.pathname) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For preset paths, only n and token are meaningful — ignore everything else
|
||||||
|
if (currentUrl.pathname.startsWith('/preset/')) {
|
||||||
|
return (
|
||||||
|
currentUrl.searchParams.get('n') === newUrl.searchParams.get('n') &&
|
||||||
|
currentUrl.searchParams.get('token') === newUrl.searchParams.get('token')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular paths, compare all search params except n and token
|
||||||
currentUrl.searchParams.delete('token');
|
currentUrl.searchParams.delete('token');
|
||||||
currentUrl.searchParams.delete('n');
|
currentUrl.searchParams.delete('n');
|
||||||
newUrl.searchParams.delete('token');
|
newUrl.searchParams.delete('token');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ ul {
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
width: min(30vw, 300px);
|
width: min(30vw, 300px);
|
||||||
|
flex: 0 0 min(30vw, 300px);
|
||||||
|
min-width: min(30vw, 300px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
+10
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
|
|||||||
'{{eventNext.delay}}',
|
'{{eventNext.delay}}',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||||
|
`{{auxtimer${index}.current}}`,
|
||||||
|
`{{auxtimer${index}.duration}}`,
|
||||||
|
`{{auxtimer${index}.playback}}`,
|
||||||
|
`{{auxtimer${index}.direction}}`,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a it of possible autocomplete suggestions
|
* Creates a it of possible autocomplete suggestions
|
||||||
* Based on RuntimeState
|
* Based on RuntimeState
|
||||||
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
|||||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||||
...eventStaticPropertiesNext,
|
...eventStaticPropertiesNext,
|
||||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||||
|
...staticAuxProperties(1),
|
||||||
|
...staticAuxProperties(2),
|
||||||
|
...staticAuxProperties(3),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -1,5 +1,7 @@
|
|||||||
import { ImportCustom, ImportMap } from 'ontime-utils';
|
import { ImportCustom, ImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||||
|
|
||||||
export type NamedImportMap = typeof namedImportMap;
|
export type NamedImportMap = typeof namedImportMap;
|
||||||
|
|
||||||
// Record of label and import name
|
// Record of label and import name
|
||||||
@@ -63,12 +65,14 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importMapKey = makeStageKey('import-map');
|
||||||
|
|
||||||
export function persistImportMap(options: NamedImportMap) {
|
export function persistImportMap(options: NamedImportMap) {
|
||||||
localStorage.setItem('import-options', JSON.stringify(options));
|
localStorage.setItem(importMapKey, JSON.stringify(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPersistImportMap(): unknown {
|
function getPersistImportMap(): unknown {
|
||||||
const options = localStorage.getItem('import-options');
|
const options = localStorage.getItem(importMapKey);
|
||||||
if (!options) {
|
if (!options) {
|
||||||
throw new Error('no import options found');
|
throw new Error('no import options found');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
|
|||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
|
||||||
import { isOntimeCloud } from '../../../../externals';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import GeneralPinInput from './composite/GeneralPinInput';
|
import GeneralPinInput from './composite/GeneralPinInput';
|
||||||
@@ -101,33 +98,6 @@ export default function GeneralSettings() {
|
|||||||
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
<Info>Changes to the time format and views language do not affect the editor view</Info>
|
||||||
<Panel.Loader isLoading={isLoading} />
|
<Panel.Loader isLoading={isLoading} />
|
||||||
<Panel.ListGroup>
|
<Panel.ListGroup>
|
||||||
<Panel.ListItem>
|
|
||||||
<Panel.Field
|
|
||||||
title='Ontime server port'
|
|
||||||
description={
|
|
||||||
isOntimeCloud
|
|
||||||
? 'Server port disabled for Ontime Cloud'
|
|
||||||
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
|
||||||
}
|
|
||||||
error={errors.serverPort?.message}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
id='serverPort'
|
|
||||||
type='number'
|
|
||||||
maxLength={5}
|
|
||||||
style={{ width: '75px' }}
|
|
||||||
disabled={isOntimeCloud}
|
|
||||||
{...register('serverPort', {
|
|
||||||
required: { value: true, message: 'Required field' },
|
|
||||||
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
|
||||||
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
|
||||||
pattern: {
|
|
||||||
value: isOnlyNumbers,
|
|
||||||
message: 'Value should be numeric',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Panel.ListItem>
|
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title='Editor pin code'
|
title='Editor pin code'
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { PortInfo } from 'ontime-types';
|
||||||
|
|
||||||
|
import { getServerPort, postServerPort } from '../../../../common/api/settings';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||||
|
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
interface ServerPortForm {
|
||||||
|
serverPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ServerPortSettings() {
|
||||||
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
setError,
|
||||||
|
formState: { isSubmitting, isDirty, isValid, errors },
|
||||||
|
} = useForm<ServerPortForm>({
|
||||||
|
mode: 'onChange',
|
||||||
|
defaultValues: { serverPort: 4001 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [pendingRestart, setPendingRestart] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const setPort = useCallback((info: PortInfo) => {
|
||||||
|
reset({ serverPort: info.port });
|
||||||
|
setPendingRestart(info.pendingRestart);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getServerPort()
|
||||||
|
.then(setPort)
|
||||||
|
.catch(() => setError('root', { message: 'Failed to load server port' }));
|
||||||
|
}, [reset, setError, setPort]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: ServerPortForm) => {
|
||||||
|
if (formData.serverPort < 1024 || formData.serverPort > 65535) {
|
||||||
|
setError('serverPort', { message: 'Port must be within range 1024 - 65535' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await postServerPort(formData.serverPort);
|
||||||
|
setPort(await getServerPort());
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { message: maybeAxiosError(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReset = async () => {
|
||||||
|
try {
|
||||||
|
setPort(await getServerPort());
|
||||||
|
} catch (error) {
|
||||||
|
setError('root', { message: 'Failed to load server port' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel.Section
|
||||||
|
as='form'
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||||
|
id='server-port-settings'
|
||||||
|
>
|
||||||
|
<Panel.Card>
|
||||||
|
<Panel.SubHeader>
|
||||||
|
Server port
|
||||||
|
<Panel.InlineElements>
|
||||||
|
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
|
||||||
|
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||||
|
Revert to saved
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='submit'
|
||||||
|
form='server-port-settings'
|
||||||
|
name='server-port-settings-submit'
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={!isDirty || !isValid || isSubmitting}
|
||||||
|
variant='primary'
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Panel.InlineElements>
|
||||||
|
</Panel.SubHeader>
|
||||||
|
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||||
|
<Panel.Divider />
|
||||||
|
<Panel.Section>
|
||||||
|
<Panel.ListGroup>
|
||||||
|
<Panel.ListItem>
|
||||||
|
<Panel.Field
|
||||||
|
title='Ontime server port'
|
||||||
|
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
|
||||||
|
error={errors.serverPort?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id='serverPort'
|
||||||
|
type='number'
|
||||||
|
maxLength={5}
|
||||||
|
style={{ width: '75px' }}
|
||||||
|
{...register('serverPort', {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
pattern: {
|
||||||
|
value: isOnlyNumbers,
|
||||||
|
message: 'Value should be numeric',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Panel.ListItem>
|
||||||
|
</Panel.ListGroup>
|
||||||
|
</Panel.Section>
|
||||||
|
</Panel.Card>
|
||||||
|
</Panel.Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||||
|
import { isDocker } from '../../../../externals';
|
||||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import GeneralSettings from './GeneralSettings';
|
import GeneralSettings from './GeneralSettings';
|
||||||
import ProjectData from './ProjectData';
|
import ProjectData from './ProjectData';
|
||||||
|
import ServerPortSettings from './ServerPortSettings';
|
||||||
import ViewSettings from './ViewSettings';
|
import ViewSettings from './ViewSettings';
|
||||||
|
|
||||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||||
|
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
|||||||
<div ref={viewRef}>
|
<div ref={viewRef}>
|
||||||
<ViewSettings />
|
<ViewSettings />
|
||||||
</div>
|
</div>
|
||||||
|
{!isDocker && (
|
||||||
|
<div ref={portRef}>
|
||||||
|
<ServerPortSettings />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||||
|
import { isDocker } from '../../externals';
|
||||||
|
|
||||||
export type SettingsOption = {
|
export type SettingsOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ const staticOptions = [
|
|||||||
{ id: 'settings__data', label: 'Project data' },
|
{ id: 'settings__data', label: 'Project data' },
|
||||||
{ id: 'settings__general', label: 'General settings' },
|
{ id: 'settings__general', label: 'General settings' },
|
||||||
{ id: 'settings__view', label: 'View settings' },
|
{ id: 'settings__view', label: 'View settings' },
|
||||||
|
{ id: 'settings__port', label: 'Server Port' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +104,14 @@ export function useAppSettingsMenu() {
|
|||||||
() =>
|
() =>
|
||||||
staticOptions.map((option) => ({
|
staticOptions.map((option) => ({
|
||||||
...option,
|
...option,
|
||||||
|
// if we are in docker don't show the port option
|
||||||
|
secondary:
|
||||||
|
'secondary' in option
|
||||||
|
? isDocker && option.id === 'settings'
|
||||||
|
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
|
||||||
|
: [...option.secondary]
|
||||||
|
: undefined,
|
||||||
|
// if there is an update then highlight the about setting
|
||||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||||
})),
|
})),
|
||||||
[data],
|
[data],
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
|||||||
<div className={style.indicatorNegative} data-active={isOvertime} />
|
<div className={style.indicatorNegative} data-active={isOvertime} />
|
||||||
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
||||||
</div>
|
</div>
|
||||||
<TimerDisplay time={isWaiting ? timer.secondaryTimer : timer.current} phase={timer.phase} />
|
<TimerDisplay
|
||||||
|
className={style.timerDisplay}
|
||||||
|
time={isWaiting ? timer.secondaryTimer : timer.current}
|
||||||
|
phase={timer.phase}
|
||||||
|
/>
|
||||||
<div className={style.status}>
|
<div className={style.status}>
|
||||||
{isWaiting ? (
|
{isWaiting ? (
|
||||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
||||||
|
import { Day } from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||||
@@ -20,7 +21,7 @@ interface OperatorEventProps {
|
|||||||
timeStart: number;
|
timeStart: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
@@ -138,7 +139,7 @@ interface OperatorEventScheduleProps {
|
|||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@ function OperatorEventSchedule({
|
|||||||
interface TimeUntilProps {
|
interface TimeUntilProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled } from 'react-icons/tb';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { Day, EndAction, EntryId, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
@@ -55,7 +55,7 @@ interface RundownEventProps {
|
|||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
gap: number;
|
gap: number;
|
||||||
isNextDay: boolean;
|
isNextDay: boolean;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
hasTriggers: boolean;
|
hasTriggers: boolean;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
IoTime,
|
IoTime,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { LuArrowDownToLine } from 'react-icons/lu';
|
import { LuArrowDownToLine } from 'react-icons/lu';
|
||||||
import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
import { Day, EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
@@ -44,7 +44,7 @@ interface RundownEventInnerProps {
|
|||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
playback?: Playback;
|
playback?: Playback;
|
||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||||
|
import { Day } from 'ontime-types';
|
||||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||||
@@ -14,7 +15,7 @@ interface RundownEventChipProps {
|
|||||||
id: string;
|
id: string;
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
className: string;
|
className: string;
|
||||||
@@ -68,7 +69,7 @@ export default function RundownEventChip({
|
|||||||
interface EventUntilProps {
|
interface EventUntilProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,19 @@
|
|||||||
import { memo, use, useEffect, useMemo } from 'react';
|
import { memo, use, useMemo } from 'react';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
|
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
import { PresetContext } from '../../common/context/PresetContext';
|
import { PresetContext } from '../../common/context/PresetContext';
|
||||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||||
import { sessionScope } from '../../externals';
|
|
||||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
|
||||||
|
|
||||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||||
import { useCuesheetPermissions } from './useTablePermissions';
|
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||||
|
|
||||||
export default memo(CuesheetTableWrapper);
|
export default memo(CuesheetTableWrapper);
|
||||||
function CuesheetTableWrapper() {
|
function CuesheetTableWrapper() {
|
||||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
|
||||||
const preset = use(PresetContext);
|
const preset = use(PresetContext);
|
||||||
|
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||||
// set permissions based on preset
|
|
||||||
useEffect(() => {
|
|
||||||
if (preset) {
|
|
||||||
const fullWrite = preset.options?.write === 'full';
|
|
||||||
setPermissions({
|
|
||||||
canChangeMode: preset.options?.write !== '-',
|
|
||||||
canCreateEntries: fullWrite,
|
|
||||||
canEditEntries: fullWrite,
|
|
||||||
canFlag: fullWrite || Boolean(preset.options?.write.includes('flag')),
|
|
||||||
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setPermissions({
|
|
||||||
canChangeMode: true,
|
|
||||||
canCreateEntries: true,
|
|
||||||
canEditEntries: true,
|
|
||||||
canFlag: true,
|
|
||||||
canShare: sessionScope === 'rw',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [preset, setPermissions]);
|
|
||||||
|
|
||||||
const [cuesheetMode] = useSessionStorage({
|
|
||||||
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
|
||||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||||
@@ -54,7 +24,16 @@ function CuesheetTableWrapper() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CuesheetDnd columns={columns}>
|
<CuesheetDnd columns={columns}>
|
||||||
{isLoading ? <EmptyPage text='Loading...' /> : <CuesheetTable columns={columns} cuesheetMode={cuesheetMode} />}
|
{isLoading ? (
|
||||||
|
<EmptyPage text='Loading...' />
|
||||||
|
) : (
|
||||||
|
<CuesheetTable
|
||||||
|
columns={columns}
|
||||||
|
cuesheetMode={cuesheetMode}
|
||||||
|
tableRoot='cuesheet'
|
||||||
|
setCuesheetMode={setCuesheetMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</CuesheetDnd>
|
</CuesheetDnd>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { OntimeView, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { AppMode } from '../../../ontimeConfig';
|
||||||
|
import { getCuesheetColumnAccessPolicy, getCuesheetPermissionsPolicy } from '../cuesheet.policies';
|
||||||
|
|
||||||
|
describe('getCuesheetPermissionsPolicy()', () => {
|
||||||
|
test('returns full permissions when there is no preset', () => {
|
||||||
|
expect(getCuesheetPermissionsPolicy(undefined, true)).toEqual({
|
||||||
|
canChangeMode: true,
|
||||||
|
canCreateEntries: true,
|
||||||
|
canEditEntries: true,
|
||||||
|
canFlag: true,
|
||||||
|
canShare: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns run-only permissions when write is disabled', () => {
|
||||||
|
const preset: URLPreset = {
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-read-only',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
options: {
|
||||||
|
read: 'full',
|
||||||
|
write: '-',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
|
||||||
|
canChangeMode: false,
|
||||||
|
canCreateEntries: false,
|
||||||
|
canEditEntries: false,
|
||||||
|
canFlag: false,
|
||||||
|
canShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows flag changes when write includes flag only', () => {
|
||||||
|
const preset: URLPreset = {
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-flag',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
options: {
|
||||||
|
read: 'full',
|
||||||
|
write: 'flag',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getCuesheetPermissionsPolicy(preset, true)).toEqual({
|
||||||
|
canChangeMode: true,
|
||||||
|
canCreateEntries: false,
|
||||||
|
canEditEntries: false,
|
||||||
|
canFlag: true,
|
||||||
|
canShare: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('defaults to full read and write when cuesheet options are absent', () => {
|
||||||
|
const preset: URLPreset = {
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-default',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||||
|
|
||||||
|
expect(policy.canRead('title')).toBe(true);
|
||||||
|
expect(policy.canWrite('title')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('column access honors granular permissions and mode', () => {
|
||||||
|
const preset: URLPreset = {
|
||||||
|
enabled: true,
|
||||||
|
alias: 'cuesheet-granular',
|
||||||
|
target: OntimeView.Cuesheet,
|
||||||
|
search: '',
|
||||||
|
options: {
|
||||||
|
read: 'cue,title',
|
||||||
|
write: 'title',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const editPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||||
|
const runPolicy = getCuesheetColumnAccessPolicy(preset, AppMode.Run);
|
||||||
|
|
||||||
|
expect(editPolicy.canRead('cue')).toBe(true);
|
||||||
|
expect(editPolicy.canRead('duration')).toBe(false);
|
||||||
|
expect(editPolicy.canWrite('title')).toBe(true);
|
||||||
|
expect(editPolicy.canWrite('cue')).toBe(false);
|
||||||
|
expect(runPolicy.canWrite('title')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,13 +34,24 @@ import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumn
|
|||||||
|
|
||||||
import style from './CuesheetTable.module.scss';
|
import style from './CuesheetTable.module.scss';
|
||||||
|
|
||||||
interface CuesheetTableProps {
|
type CuesheetTableBaseProps = {
|
||||||
columns: ColumnDef<ExtendedEntry>[];
|
columns: ColumnDef<ExtendedEntry>[];
|
||||||
cuesheetMode: AppMode;
|
cuesheetMode: AppMode;
|
||||||
tableRoot?: 'editor' | 'cuesheet';
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cuesheet' }: CuesheetTableProps) {
|
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
|
tableRoot: 'editor';
|
||||||
|
setCuesheetMode?: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||||
|
tableRoot: 'cuesheet';
|
||||||
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||||
|
|
||||||
|
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||||
const { data, status } = useFlatRundownWithMetadata();
|
const { data, status } = useFlatRundownWithMetadata();
|
||||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||||
|
|
||||||
@@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
|||||||
return <EmptyPage text='Loading...' />;
|
return <EmptyPage text='Loading...' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// control components need different implementations for handling permissions
|
|
||||||
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TableRootSettings
|
{tableRoot === 'editor' ? (
|
||||||
columns={allLeafColumns}
|
<EditorTableSettings
|
||||||
handleResetResizing={resetColumnResizing}
|
columns={allLeafColumns}
|
||||||
handleResetReordering={resetColumnOrder}
|
handleResetResizing={resetColumnResizing}
|
||||||
handleClearToggles={setAllVisible}
|
handleResetReordering={resetColumnOrder}
|
||||||
/>
|
handleClearToggles={setAllVisible}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CuesheetTableSettings
|
||||||
|
columns={allLeafColumns}
|
||||||
|
cuesheetMode={cuesheetMode}
|
||||||
|
setCuesheetMode={setCuesheetMode}
|
||||||
|
handleResetResizing={resetColumnResizing}
|
||||||
|
handleResetReordering={resetColumnOrder}
|
||||||
|
handleClearToggles={setAllVisible}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<TableVirtuoso
|
<TableVirtuoso
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
+2
-9
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
|
|||||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||||
import { AppMode } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
|
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||||
|
|
||||||
import DurationInput from './DurationInput';
|
import DurationInput from './DurationInput';
|
||||||
import EditableImage from './EditableImage';
|
import EditableImage from './EditableImage';
|
||||||
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
|
|||||||
preset: URLPreset | undefined,
|
preset: URLPreset | undefined,
|
||||||
): ColumnDef<ExtendedEntry>[] {
|
): ColumnDef<ExtendedEntry>[] {
|
||||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||||
const fullRead = preset ? preset.options?.read === 'full' : true;
|
|
||||||
const fullWrite = preset ? preset.options?.write === 'full' : true;
|
|
||||||
const canWriteKeys = preset?.options?.write ? new Set(preset.options.write.split(',')) : new Set<string>();
|
|
||||||
const canReadKeys = preset?.options?.read ? new Set(preset.options.read.split(',')) : new Set<string>();
|
|
||||||
|
|
||||||
// helpers to check read/write for a given key
|
|
||||||
const canRead = (key: string) => fullRead || canReadKeys.has(key);
|
|
||||||
const canWrite = (key: string) => modeAllowsWrite && (fullWrite || canWriteKeys.has(key));
|
|
||||||
|
|
||||||
if (canRead('flag')) {
|
if (canRead('flag')) {
|
||||||
columnsDef.push({
|
columnsDef.push({
|
||||||
|
|||||||
+21
-18
@@ -1,20 +1,18 @@
|
|||||||
import { ReactNode, use } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
import { IoBookOutline, IoChevronDown, IoOptions } from 'react-icons/io5';
|
||||||
import { Popover } from '@base-ui/react/popover';
|
import { Popover } from '@base-ui/react/popover';
|
||||||
import { Toggle } from '@base-ui/react/toggle';
|
import { Toggle } from '@base-ui/react/toggle';
|
||||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||||
import { Toolbar } from '@base-ui/react/toolbar';
|
import { Toolbar } from '@base-ui/react/toolbar';
|
||||||
import { useSessionStorage } from '@mantine/hooks';
|
|
||||||
import type { Column } from '@tanstack/react-table';
|
import type { Column } from '@tanstack/react-table';
|
||||||
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||||
import { PresetContext } from '../../../../common/context/PresetContext';
|
|
||||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||||
import { cx } from '../../../../common/utils/styleUtils';
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
import { AppMode } from '../../../../ontimeConfig';
|
||||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||||
|
|
||||||
@@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss';
|
|||||||
|
|
||||||
interface CuesheetTableSettingsProps {
|
interface CuesheetTableSettingsProps {
|
||||||
columns: Column<ExtendedEntry, unknown>[];
|
columns: Column<ExtendedEntry, unknown>[];
|
||||||
|
cuesheetMode: AppMode;
|
||||||
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
handleResetResizing: () => void;
|
handleResetResizing: () => void;
|
||||||
handleResetReordering: () => void;
|
handleResetReordering: () => void;
|
||||||
handleClearToggles: () => void;
|
handleClearToggles: () => void;
|
||||||
@@ -42,19 +42,16 @@ export interface ColumnSettingsProps {
|
|||||||
|
|
||||||
export default function CuesheetTableSettings({
|
export default function CuesheetTableSettings({
|
||||||
columns,
|
columns,
|
||||||
|
cuesheetMode,
|
||||||
|
setCuesheetMode,
|
||||||
handleResetResizing,
|
handleResetResizing,
|
||||||
handleResetReordering,
|
handleResetReordering,
|
||||||
handleClearToggles,
|
handleClearToggles,
|
||||||
}: CuesheetTableSettingsProps) {
|
}: CuesheetTableSettingsProps) {
|
||||||
|
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||||
const preset = use(PresetContext);
|
|
||||||
const options = usePersistedCuesheetOptions();
|
const options = usePersistedCuesheetOptions();
|
||||||
|
|
||||||
const [cuesheetMode, setCuesheetMode] = useSessionStorage({
|
|
||||||
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
|
||||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||||
// we need to stop user from deselecting a mode
|
// we need to stop user from deselecting a mode
|
||||||
const newValue = mode.at(0);
|
const newValue = mode.at(0);
|
||||||
@@ -71,14 +68,20 @@ export default function CuesheetTableSettings({
|
|||||||
handleResetReordering={handleResetReordering}
|
handleResetReordering={handleResetReordering}
|
||||||
handleClearToggles={handleClearToggles}
|
handleClearToggles={handleClearToggles}
|
||||||
/>
|
/>
|
||||||
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
|
{canChangeMode && (
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
<ToggleGroup
|
||||||
Run
|
value={[cuesheetMode]}
|
||||||
</Toolbar.Button>
|
onValueChange={toggleCuesheetMode}
|
||||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
className={cx([style.group, style.apart])}
|
||||||
Edit
|
>
|
||||||
</Toolbar.Button>
|
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||||
</ToggleGroup>
|
Run
|
||||||
|
</Toolbar.Button>
|
||||||
|
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||||
|
Edit
|
||||||
|
</Toolbar.Button>
|
||||||
|
</ToggleGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{canShare && (
|
{canShare && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { AppMode } from '../../ontimeConfig';
|
||||||
|
|
||||||
|
import type { CuesheetPermissions } from './useTablePermissions';
|
||||||
|
|
||||||
|
function getPermissionKeys(permission: string | undefined): Set<string> {
|
||||||
|
return permission ? new Set(permission.split(',')) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCuesheetPermissions(readPermission: string | undefined, writePermission: string | undefined) {
|
||||||
|
const readKeys = getPermissionKeys(readPermission);
|
||||||
|
const writeKeys = getPermissionKeys(writePermission);
|
||||||
|
const fullRead = readPermission == null || readPermission === 'full';
|
||||||
|
const fullWrite = writePermission == null || writePermission === 'full';
|
||||||
|
|
||||||
|
return {
|
||||||
|
writePermission,
|
||||||
|
readKeys,
|
||||||
|
writeKeys,
|
||||||
|
fullRead,
|
||||||
|
fullWrite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCuesheetPermissionsPolicy(
|
||||||
|
preset: URLPreset | undefined,
|
||||||
|
canShareInSession: boolean,
|
||||||
|
): CuesheetPermissions {
|
||||||
|
if (!preset) {
|
||||||
|
return {
|
||||||
|
canChangeMode: true,
|
||||||
|
canCreateEntries: true,
|
||||||
|
canEditEntries: true,
|
||||||
|
canFlag: true,
|
||||||
|
canShare: canShareInSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { writePermission, writeKeys, fullWrite } = getCuesheetPermissions(
|
||||||
|
preset?.options?.read,
|
||||||
|
preset?.options?.write,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canChangeMode: writePermission !== '-',
|
||||||
|
canCreateEntries: fullWrite,
|
||||||
|
canEditEntries: fullWrite,
|
||||||
|
canFlag: fullWrite || writeKeys.has('flag'),
|
||||||
|
canShare: false, // TODO: should be sessionScope === 'rw' when we have granular scopes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCuesheetColumnAccessPolicy(preset: URLPreset | undefined, cuesheetMode: AppMode) {
|
||||||
|
const { readKeys, writeKeys, fullRead, fullWrite } = getCuesheetPermissions(
|
||||||
|
preset?.options?.read,
|
||||||
|
preset?.options?.write,
|
||||||
|
);
|
||||||
|
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
canRead: (key: string) => fullRead || readKeys.has(key),
|
||||||
|
canWrite: (key: string) => modeAllowsWrite && (fullWrite || writeKeys.has(key)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useSessionStorage } from '@mantine/hooks';
|
||||||
|
import { URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
|
import { sessionScope } from '../../externals';
|
||||||
|
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||||
|
|
||||||
|
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
||||||
|
import { useCuesheetPermissions } from './useTablePermissions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
||||||
|
*/
|
||||||
|
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||||
|
cuesheetMode: AppMode;
|
||||||
|
setCuesheetMode: (mode: AppMode) => void;
|
||||||
|
} {
|
||||||
|
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||||
|
const canShareInSession = sessionScope === 'rw';
|
||||||
|
const permissions = useMemo(
|
||||||
|
() => getCuesheetPermissionsPolicy(preset, canShareInSession),
|
||||||
|
[preset, canShareInSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [storedCuesheetMode, setStoredCuesheetMode] = useSessionStorage({
|
||||||
|
key: preset ? `${preset.alias}${sessionKeys.cuesheetMode}` : sessionKeys.cuesheetMode,
|
||||||
|
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
|
||||||
|
const setCuesheetMode = useCallback(
|
||||||
|
(mode: AppMode) => {
|
||||||
|
if (!permissions.canChangeMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStoredCuesheetMode(mode);
|
||||||
|
},
|
||||||
|
[permissions.canChangeMode, setStoredCuesheetMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep the shared permissions store aligned with the active preset policy.
|
||||||
|
useEffect(() => {
|
||||||
|
setPermissions(permissions);
|
||||||
|
}, [permissions, setPermissions]);
|
||||||
|
|
||||||
|
// Force Run mode whenever the policy forbids mode switching.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!permissions.canChangeMode) {
|
||||||
|
setStoredCuesheetMode((mode) => (mode === AppMode.Run ? mode : AppMode.Run));
|
||||||
|
}
|
||||||
|
}, [permissions.canChangeMode, setStoredCuesheetMode]);
|
||||||
|
|
||||||
|
return { cuesheetMode, setCuesheetMode };
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ interface CuesheetPermissionsStore {
|
|||||||
canEditEntries: boolean;
|
canEditEntries: boolean;
|
||||||
canFlag: boolean;
|
canFlag: boolean;
|
||||||
canShare: boolean;
|
canShare: boolean;
|
||||||
setPermissions: (permissions: Omit<CuesheetPermissionsStore, 'setPermissions'>) => void;
|
setPermissions: (permissions: CuesheetPermissions) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CuesheetPermissions = Omit<CuesheetPermissionsStore, 'setPermissions'>;
|
||||||
|
|
||||||
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
|
export const useCuesheetPermissions = create<CuesheetPermissionsStore>((set) => ({
|
||||||
canChangeMode: false,
|
canChangeMode: false,
|
||||||
canCreateEntries: false,
|
canCreateEntries: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { Day, OntimeEvent } from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||||
|
|
||||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
@@ -78,7 +78,7 @@ function TitleListItem({
|
|||||||
interface TitleListTimeUntilChipProps {
|
interface TitleListTimeUntilChipProps {
|
||||||
timeStart: number;
|
timeStart: number;
|
||||||
delay: number;
|
delay: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
isLoaded: boolean;
|
isLoaded: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
|
import { Day } from 'ontime-types';
|
||||||
|
|
||||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||||
import { getProgress } from '../../common/utils/getProgress';
|
import { getProgress } from '../../common/utils/getProgress';
|
||||||
@@ -20,7 +21,7 @@ interface TimelineEntryProps {
|
|||||||
left: number;
|
left: number;
|
||||||
status: ProgressStatus;
|
status: ProgressStatus;
|
||||||
start: number;
|
start: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -119,7 +120,7 @@ export function TimelineEntry({
|
|||||||
interface TimelineEntryStatusProps {
|
interface TimelineEntryStatusProps {
|
||||||
delay: number;
|
delay: number;
|
||||||
start: number;
|
start: number;
|
||||||
dayOffset: number;
|
dayOffset: Day;
|
||||||
totalGap: number;
|
totalGap: number;
|
||||||
isLinkedToLoaded: boolean;
|
isLinkedToLoaded: boolean;
|
||||||
status: ProgressStatus;
|
status: ProgressStatus;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
||||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||||
@@ -10,6 +9,7 @@ import * as httpClient from '../clients/http.client.js';
|
|||||||
|
|
||||||
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
|
||||||
import { RuntimeState } from '../../../stores/runtimeState.js';
|
import { RuntimeState } from '../../../stores/runtimeState.js';
|
||||||
|
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||||
@@ -40,9 +40,20 @@ describe('triggerAction()', () => {
|
|||||||
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
|
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
|
||||||
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
|
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.mock('../../../stores/EventStore.js', () => {
|
||||||
|
// Create a small mock store
|
||||||
|
return {
|
||||||
|
eventStore: {
|
||||||
|
poll: vi.fn().mockImplementation(() => makeRuntimeStoreData()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { });
|
||||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
|
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { });
|
||||||
|
|
||||||
await deleteAllTriggers();
|
await deleteAllTriggers();
|
||||||
const oscAutomation = await addAutomation({
|
const oscAutomation = await addAutomation({
|
||||||
@@ -70,26 +81,25 @@ describe('triggerAction()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should trigger automations for a given action', () => {
|
it('should trigger automations for a given action', () => {
|
||||||
const state = makeRuntimeStateData();
|
triggerAutomations(TimerLifeCycle.onLoad);
|
||||||
triggerAutomations(TimerLifeCycle.onLoad, state);
|
|
||||||
expect(oscSpy).toHaveBeenCalledTimes(1);
|
expect(oscSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(httpSpy).not.toBeCalled();
|
expect(httpSpy).not.toBeCalled();
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onStart, state);
|
triggerAutomations(TimerLifeCycle.onStart);
|
||||||
expect(oscClient.emitOSC).not.toBeCalled();
|
expect(oscClient.emitOSC).not.toBeCalled();
|
||||||
expect(httpSpy).not.toBeCalled();
|
expect(httpSpy).not.toBeCalled();
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onFinish, state);
|
triggerAutomations(TimerLifeCycle.onFinish);
|
||||||
expect(oscSpy).not.toBeCalled();
|
expect(oscSpy).not.toBeCalled();
|
||||||
expect(httpSpy).toHaveBeenCalledTimes(1);
|
expect(httpSpy).toHaveBeenCalledTimes(1);
|
||||||
oscSpy.mockReset();
|
oscSpy.mockReset();
|
||||||
httpSpy.mockReset();
|
httpSpy.mockReset();
|
||||||
|
|
||||||
triggerAutomations(TimerLifeCycle.onStop, state);
|
triggerAutomations(TimerLifeCycle.onStop);
|
||||||
expect(oscSpy).not.toBeCalled();
|
expect(oscSpy).not.toBeCalled();
|
||||||
expect(httpSpy).not.toBeCalled();
|
expect(httpSpy).not.toBeCalled();
|
||||||
});
|
});
|
||||||
@@ -540,7 +550,7 @@ describe('testConditions()', () => {
|
|||||||
|
|
||||||
describe('for all filter rule', () => {
|
describe('for all filter rule', () => {
|
||||||
it('should return true when all filters are true', () => {
|
it('should return true when all filters are true', () => {
|
||||||
const mockStore = makeRuntimeStateData({
|
const mockStore = makeRuntimeStoreData({
|
||||||
clock: 10,
|
clock: 10,
|
||||||
eventNow: makeOntimeEvent({
|
eventNow: makeOntimeEvent({
|
||||||
title: 'test',
|
title: 'test',
|
||||||
@@ -560,7 +570,7 @@ describe('testConditions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if any filters are false', () => {
|
it('should return false if any filters are false', () => {
|
||||||
const mockStore = makeRuntimeStateData({
|
const mockStore = makeRuntimeStoreData({
|
||||||
clock: 10,
|
clock: 10,
|
||||||
eventNow: makeOntimeEvent({
|
eventNow: makeOntimeEvent({
|
||||||
title: 'test',
|
title: 'test',
|
||||||
@@ -582,7 +592,7 @@ describe('testConditions()', () => {
|
|||||||
|
|
||||||
describe('for any filter rule', () => {
|
describe('for any filter rule', () => {
|
||||||
it('should return true when all filters are true', () => {
|
it('should return true when all filters are true', () => {
|
||||||
const mockStore = makeRuntimeStateData({
|
const mockStore = makeRuntimeStoreData({
|
||||||
clock: 10,
|
clock: 10,
|
||||||
eventNow: makeOntimeEvent({
|
eventNow: makeOntimeEvent({
|
||||||
title: 'test',
|
title: 'test',
|
||||||
@@ -602,7 +612,7 @@ describe('testConditions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return true if any filters are true', () => {
|
it('should return true if any filters are true', () => {
|
||||||
const mockStore = makeRuntimeStateData({
|
const mockStore = makeRuntimeStoreData({
|
||||||
clock: 10,
|
clock: 10,
|
||||||
eventNow: makeOntimeEvent({
|
eventNow: makeOntimeEvent({
|
||||||
title: 'not-test',
|
title: 'not-test',
|
||||||
@@ -622,7 +632,7 @@ describe('testConditions()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if all filters are false', () => {
|
it('should return false if all filters are false', () => {
|
||||||
const mockStore = makeRuntimeStateData({
|
const mockStore = makeRuntimeStoreData({
|
||||||
clock: 10,
|
clock: 10,
|
||||||
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
|
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
isOntimeAction,
|
isOntimeAction,
|
||||||
isOSCOutput,
|
isOSCOutput,
|
||||||
LogOrigin,
|
LogOrigin,
|
||||||
|
RuntimeStore,
|
||||||
TimerLifeCycle,
|
TimerLifeCycle,
|
||||||
type AutomationFilter,
|
type AutomationFilter,
|
||||||
type AutomationOutput,
|
type AutomationOutput,
|
||||||
@@ -10,29 +11,30 @@ import {
|
|||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { getPropertyFromPath } from 'ontime-utils';
|
import { getPropertyFromPath } from 'ontime-utils';
|
||||||
|
|
||||||
import { logger } from '../../classes/Logger.js';
|
|
||||||
import { getState, type RuntimeState } from '../../stores/runtimeState.js';
|
|
||||||
import { isOntimeCloud } from '../../setup/environment.js';
|
|
||||||
|
|
||||||
import { emitOSC } from './clients/osc.client.js';
|
import { emitOSC } from './clients/osc.client.js';
|
||||||
import { emitHTTP } from './clients/http.client.js';
|
import { emitHTTP } from './clients/http.client.js';
|
||||||
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
||||||
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
|
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
|
||||||
import { toOntimeAction } from './clients/ontime.client.js';
|
import { toOntimeAction } from './clients/ontime.client.js';
|
||||||
|
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
import { isOntimeCloud } from '../../setup/environment.js';
|
||||||
|
import { eventStore } from '../../stores/EventStore.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||||
*/
|
*/
|
||||||
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||||
if (!getAutomationsEnabled()) {
|
if (!getAutomationsEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const store = eventStore.poll();
|
||||||
|
|
||||||
let triggers = getAutomationTriggers();
|
let triggers = getAutomationTriggers();
|
||||||
|
|
||||||
// get triggers from event
|
// get triggers from event
|
||||||
if (state.eventNow?.triggers) {
|
if (store.eventNow?.triggers) {
|
||||||
triggers = triggers.concat(state.eventNow.triggers);
|
triggers = triggers.concat(store.eventNow.triggers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// note: there are no onStop triggers in event
|
// note: there are no onStop triggers in event
|
||||||
@@ -51,9 +53,9 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
|||||||
if (!automation || automation.outputs.length === 0) {
|
if (!automation || automation.outputs.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
|
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
|
||||||
if (shouldSend) {
|
if (shouldSend) {
|
||||||
send(automation.outputs, state);
|
send(automation.outputs, store);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,7 +64,8 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
|||||||
* Exposes a method for bypassing the condition check and testing the sending of an output
|
* Exposes a method for bypassing the condition check and testing the sending of an output
|
||||||
*/
|
*/
|
||||||
export function testOutput(payload: AutomationOutput) {
|
export function testOutput(payload: AutomationOutput) {
|
||||||
send([payload]);
|
const store = eventStore.poll();
|
||||||
|
send([payload], store);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,7 +74,7 @@ export function testOutput(payload: AutomationOutput) {
|
|||||||
export function testConditions(
|
export function testConditions(
|
||||||
filters: AutomationFilter[],
|
filters: AutomationFilter[],
|
||||||
filterRule: FilterRule,
|
filterRule: FilterRule,
|
||||||
state: Partial<RuntimeState>,
|
store: Partial<RuntimeStore>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (filters.length === 0) {
|
if (filters.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
@@ -86,7 +89,7 @@ export function testConditions(
|
|||||||
function evaluateCondition(filter: AutomationFilter): boolean {
|
function evaluateCondition(filter: AutomationFilter): boolean {
|
||||||
const { field, operator, value } = filter;
|
const { field, operator, value } = filter;
|
||||||
const lowerCasedValue = value.toLowerCase();
|
const lowerCasedValue = value.toLowerCase();
|
||||||
const fieldValue = getPropertyFromPath(field, state);
|
const fieldValue = getPropertyFromPath(field, store);
|
||||||
|
|
||||||
// if value is empty string, the user could be meaning to check if the value does not exist
|
// if value is empty string, the user could be meaning to check if the value does not exist
|
||||||
// we use loose equality to be able to check for converted values (eg '10' == 10)
|
// we use loose equality to be able to check for converted values (eg '10' == 10)
|
||||||
@@ -115,13 +118,12 @@ export function testConditions(
|
|||||||
* Handles preparing and sending of the data
|
* Handles preparing and sending of the data
|
||||||
* Returns a boolean indicating whether a message was sent
|
* Returns a boolean indicating whether a message was sent
|
||||||
*/
|
*/
|
||||||
function send(output: AutomationOutput[], state?: RuntimeState) {
|
function send(output: AutomationOutput[], store: RuntimeStore) {
|
||||||
const stateSnapshot = state ?? getState();
|
|
||||||
output.forEach((payload) => {
|
output.forEach((payload) => {
|
||||||
if (isOSCOutput(payload) && !isOntimeCloud) {
|
if (isOSCOutput(payload) && !isOntimeCloud) {
|
||||||
emitOSC(payload, stateSnapshot);
|
emitOSC(payload, store);
|
||||||
} else if (isHTTPOutput(payload)) {
|
} else if (isHTTPOutput(payload)) {
|
||||||
emitHTTP(payload, stateSnapshot);
|
emitHTTP(payload, store);
|
||||||
} else if (isOntimeAction(payload)) {
|
} else if (isOntimeAction(payload)) {
|
||||||
toOntimeAction(payload);
|
toOntimeAction(payload);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { HTTPOutput, LogOrigin } from 'ontime-types';
|
import { DeepReadonly } from 'ts-essentials';
|
||||||
|
import { HTTPOutput, LogOrigin, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { logger } from '../../../classes/Logger.js';
|
import { logger } from '../../../classes/Logger.js';
|
||||||
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
|
||||||
|
|
||||||
import { parseTemplateNested } from '../automation.utils.js';
|
import { parseTemplateNested } from '../automation.utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expose possibility to send a message using HTTP protocol
|
* Expose possibility to send a message using HTTP protocol
|
||||||
*/
|
*/
|
||||||
export function emitHTTP(output: HTTPOutput, state: RuntimeState) {
|
export function emitHTTP(output: HTTPOutput, store: DeepReadonly<RuntimeStore>) {
|
||||||
const url = preparePayload(output, state);
|
const url = preparePayload(output, store);
|
||||||
emit(url);
|
emit(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses the state and prepares payload to be emitted */
|
/** Parses the state and prepares payload to be emitted */
|
||||||
function preparePayload(output: HTTPOutput, state: RuntimeState): string {
|
function preparePayload(output: HTTPOutput, state: DeepReadonly<RuntimeStore>): string {
|
||||||
const parsedUrl = parseTemplateNested(output.url, state);
|
const parsedUrl = parseTemplateNested(output.url, state);
|
||||||
return parsedUrl;
|
return parsedUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { LogOrigin, OSCOutput } from 'ontime-types';
|
import { LogOrigin, OSCOutput, RuntimeStore } from 'ontime-types';
|
||||||
|
|
||||||
import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min';
|
import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min';
|
||||||
import * as dgram from 'node:dgram';
|
import * as dgram from 'node:dgram';
|
||||||
|
|
||||||
import { logger } from '../../../classes/Logger.js';
|
import { logger } from '../../../classes/Logger.js';
|
||||||
import { type RuntimeState } from '../../../stores/runtimeState.js';
|
|
||||||
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||||
|
import { DeepReadonly } from 'ts-essentials';
|
||||||
|
|
||||||
const udpClient = dgram.createSocket('udp4');
|
const udpClient = dgram.createSocket('udp4');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expose possibility to send a message using OSC protocol
|
* Expose possibility to send a message using OSC protocol
|
||||||
*/
|
*/
|
||||||
export function emitOSC(output: OSCOutput, state: RuntimeState) {
|
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
||||||
const message = preparePayload(output, state);
|
const message = preparePayload(output, store);
|
||||||
emit(output.targetIP, output.targetPort, message);
|
emit(output.targetIP, output.targetPort, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses the state and prepares payload to be emitted */
|
/** Parses the state and prepares payload to be emitted */
|
||||||
function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput {
|
function preparePayload(output: OSCOutput, store: DeepReadonly<RuntimeStore>): OscPacketInput {
|
||||||
// check for templates in the address
|
// check for templates in the address
|
||||||
const parsedAddress = parseTemplateNested(output.address, state);
|
const parsedAddress = parseTemplateNested(output.address, store);
|
||||||
|
|
||||||
// check for templates in the arguments
|
// check for templates in the arguments
|
||||||
const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined;
|
const parsedArguments = output.args ? parseTemplateNested(output.args, store) : undefined;
|
||||||
// check we have the correct type
|
// check we have the correct type
|
||||||
const oscArguments = stringToOSCArgs(parsedArguments);
|
const oscArguments = stringToOSCArgs(parsedArguments);
|
||||||
return { address: parsedAddress, args: oscArguments };
|
return { address: parsedAddress, args: oscArguments };
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { parseUrlPresets } from '../url-presets/urlPresets.parser.js';
|
|||||||
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
|
import { parseViewSettings } from '../view-settings/viewSettings.parser.js';
|
||||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||||
import * as v3 from './migration/db.migration.v3.js';
|
import * as v3 from './migration/db.migration.v3.js';
|
||||||
|
import * as v4 from './migration/db.migration.v4.js';
|
||||||
|
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||||
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
type ParsingError = {
|
type ParsingError = {
|
||||||
context: string;
|
context: string;
|
||||||
@@ -21,21 +24,45 @@ type ParsingError = {
|
|||||||
* @param {object} jsonData - project file to be parsed
|
* @param {object} jsonData - project file to be parsed
|
||||||
* @returns {object} parsed object
|
* @returns {object} parsed object
|
||||||
*/
|
*/
|
||||||
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
export function parseDatabaseModel(
|
||||||
|
jsonData: Partial<DatabaseModel>,
|
||||||
|
initialLoad = false,
|
||||||
|
): {
|
||||||
data: DatabaseModel;
|
data: DatabaseModel;
|
||||||
errors: ParsingError[];
|
errors: ParsingError[];
|
||||||
migrated: boolean;
|
migrated: boolean;
|
||||||
} {
|
} {
|
||||||
|
|
||||||
let migrated = false;
|
let migrated = false;
|
||||||
let migratedData = jsonData;
|
let migratedData = jsonData;
|
||||||
|
const errors: ParsingError[] = [];
|
||||||
|
|
||||||
if (v3.shouldUseThisMigration(jsonData)) {
|
if (v3.shouldUseThisMigration(jsonData)) {
|
||||||
try {
|
try {
|
||||||
migrated = true;
|
migrated = true;
|
||||||
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
|
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
|
||||||
migratedData = v3.migrateAllData(jsonData);
|
migratedData = v3.migrateAllData(jsonData);
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
logger.error(LogOrigin.Server, 'Failed to migrate the data');
|
logger.error(LogOrigin.Server, 'Failed to migrate the data');
|
||||||
|
errors.push({ context: 'v3 migration', message: getErrorMessage(error) });
|
||||||
migratedData = jsonData;
|
migratedData = jsonData;
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (v4.shouldMigrateServerPort(migratedData)) {
|
||||||
|
try {
|
||||||
|
migrated = true;
|
||||||
|
logger.warning(LogOrigin.Server, 'Migrating serverPort from settings to AppState');
|
||||||
|
const { db, serverPort } = v4.migrateServerPort(migratedData);
|
||||||
|
if (initialLoad && serverPort) portManager.migratePortFromProjectFile(serverPort);
|
||||||
|
migratedData = db;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(LogOrigin.Server, 'Failed to migrate serverPort');
|
||||||
|
errors.push({ context: 'v4 migration', message: getErrorMessage(error) });
|
||||||
|
migratedData = jsonData;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +70,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
|||||||
// this may throw
|
// this may throw
|
||||||
const settings = parseSettings(migratedData);
|
const settings = parseSettings(migratedData);
|
||||||
|
|
||||||
const errors: ParsingError[] = [];
|
|
||||||
const makeEmitError = (context: string) => (message: string) => {
|
const makeEmitError = (context: string) => (message: string) => {
|
||||||
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
||||||
errors.push({ context, message });
|
errors.push({ context, message });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Trigger,
|
Trigger,
|
||||||
URLPreset,
|
URLPreset,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
|
Day,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
customFieldLabelToKey,
|
customFieldLabelToKey,
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { is } from '../../../utils/is.js';
|
import { is } from '../../../utils/is.js';
|
||||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
|
||||||
import { getPartialProject } from '../../../models/dataModel.js';
|
import { getPartialProject } from '../../../models/dataModel.js';
|
||||||
|
|
||||||
// the methodology of the migrations is to just change the necessary keys to match with v4
|
// the methodology of the migrations is to just change the necessary keys to match with v4
|
||||||
@@ -69,12 +69,12 @@ type old_Settings = {
|
|||||||
* migrates a settings from v3 to v4
|
* migrates a settings from v3 to v4
|
||||||
* - update the version number
|
* - update the version number
|
||||||
*/
|
*/
|
||||||
export function migrateSettings(jsonData: object): Settings | undefined {
|
export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined {
|
||||||
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
|
if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) {
|
||||||
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
||||||
jsonData.settings,
|
jsonData.settings,
|
||||||
) as old_Settings;
|
) as old_Settings;
|
||||||
return { version: ONTIME_VERSION, serverPort, editorKey, operatorKey, timeFormat, language };
|
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ export function migrateRundown(
|
|||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: -1,
|
revision: -1,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
});
|
});
|
||||||
} else if (entry.type === 'block') {
|
} else if (entry.type === 'block') {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { DatabaseModel, Settings } from 'ontime-types';
|
||||||
|
import { is } from '../../../utils/is.js';
|
||||||
|
|
||||||
|
export function shouldMigrateServerPort(jsonData: object): boolean {
|
||||||
|
return (
|
||||||
|
is.objectWithKeys(jsonData, ['settings']) &&
|
||||||
|
is.object(jsonData.settings) &&
|
||||||
|
is.objectWithKeys(jsonData.settings, ['version', 'serverPort']) &&
|
||||||
|
typeof jsonData.settings.version === 'string' &&
|
||||||
|
jsonData.settings.version.split('.')[0] === '4' &&
|
||||||
|
Number(jsonData.settings.version.split('.')[1]) <= 4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||||
|
db: Partial<DatabaseModel>;
|
||||||
|
serverPort?: number;
|
||||||
|
} {
|
||||||
|
const db = structuredClone(jsonData);
|
||||||
|
const settings = db.settings as Partial<Settings & { serverPort: number }>;
|
||||||
|
const editorKey = settings?.editorKey;
|
||||||
|
const operatorKey = settings?.operatorKey;
|
||||||
|
const timeFormat = settings?.timeFormat;
|
||||||
|
const language = settings?.language;
|
||||||
|
const version = '4.5.0';
|
||||||
|
db.settings = {
|
||||||
|
version,
|
||||||
|
editorKey,
|
||||||
|
operatorKey,
|
||||||
|
timeFormat,
|
||||||
|
language,
|
||||||
|
app: 'ontime',
|
||||||
|
} as Settings;
|
||||||
|
return { db, serverPort: settings?.serverPort };
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
|
DatabaseModel,
|
||||||
EndAction,
|
EndAction,
|
||||||
OntimeView,
|
OntimeView,
|
||||||
ProjectData,
|
ProjectData,
|
||||||
@@ -14,8 +15,7 @@ import {
|
|||||||
ViewSettings,
|
ViewSettings,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import * as v3 from './db.migration.v3.js';
|
import * as v3 from './db.migration.v3.js';
|
||||||
|
import * as v4 from './db.migration.v4.js';
|
||||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
|
||||||
|
|
||||||
describe('v3 to v4', () => {
|
describe('v3 to v4', () => {
|
||||||
const oldDb = {
|
const oldDb = {
|
||||||
@@ -175,8 +175,8 @@ describe('v3 to v4', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test('migrate settings', () => {
|
test('migrate settings', () => {
|
||||||
const expectSettings: Settings = {
|
const expectSettings: Settings & { serverPort: number } = {
|
||||||
version: ONTIME_VERSION,
|
version: '4.0.0',
|
||||||
serverPort: 4001,
|
serverPort: 4001,
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
@@ -468,7 +468,7 @@ describe('v3 to v4', () => {
|
|||||||
|
|
||||||
const expectedAutomation: AutomationSettings = {
|
const expectedAutomation: AutomationSettings = {
|
||||||
enabledAutomations: true,
|
enabledAutomations: true,
|
||||||
enabledOscIn: true,
|
enabledOscIn: false,
|
||||||
oscPortIn: 8888,
|
oscPortIn: 8888,
|
||||||
triggers: [
|
triggers: [
|
||||||
{
|
{
|
||||||
@@ -493,3 +493,101 @@ describe('v3 to v4', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('v4 remove server port', () => {
|
||||||
|
const demoDb = {
|
||||||
|
rundowns: {},
|
||||||
|
project: {
|
||||||
|
title: 'Eurovision Song Contest',
|
||||||
|
description: 'Turin 2022',
|
||||||
|
url: 'www.github.com/cpvalente/ontime',
|
||||||
|
info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal',
|
||||||
|
logo: null,
|
||||||
|
custom: [],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
app: 'ontime',
|
||||||
|
version: '4.0.0',
|
||||||
|
serverPort: 4001,
|
||||||
|
editorKey: null,
|
||||||
|
operatorKey: null,
|
||||||
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
viewSettings: {
|
||||||
|
dangerColor: '#ff7300',
|
||||||
|
normalColor: '#ffffffcc',
|
||||||
|
overrideStyles: false,
|
||||||
|
warningColor: '#ffa528',
|
||||||
|
},
|
||||||
|
customFields: {
|
||||||
|
song: {
|
||||||
|
label: 'Song',
|
||||||
|
type: 'text',
|
||||||
|
colour: '#339E4E',
|
||||||
|
},
|
||||||
|
artist: {
|
||||||
|
label: 'Artist',
|
||||||
|
type: 'text',
|
||||||
|
colour: '#3E75E8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
urlPresets: [
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'clock',
|
||||||
|
target: 'timer',
|
||||||
|
search:
|
||||||
|
'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
alias: 'minimal',
|
||||||
|
target: 'timer',
|
||||||
|
search:
|
||||||
|
'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
automation: {
|
||||||
|
enabledAutomations: false,
|
||||||
|
enabledOscIn: true,
|
||||||
|
oscPortIn: 8888,
|
||||||
|
triggers: [],
|
||||||
|
automations: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should migrate if server port exists', () => {
|
||||||
|
expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not migrate if the version is newer', () => {
|
||||||
|
expect(
|
||||||
|
v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel),
|
||||||
|
).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not migrate if there is no server port', () => {
|
||||||
|
expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove server port from project', () => {
|
||||||
|
const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel);
|
||||||
|
expect(result).not.toHaveProperty('settings.serverPort');
|
||||||
|
expect(serverPort).toBe(4001);
|
||||||
|
expect(result.automation).toMatchObject(demoDb.automation);
|
||||||
|
expect(result.customFields).toMatchObject(demoDb.customFields);
|
||||||
|
expect(result.project).toMatchObject(demoDb.project);
|
||||||
|
expect(result.rundowns).toMatchObject(demoDb.rundowns);
|
||||||
|
expect(result.settings).toMatchObject({
|
||||||
|
app: 'ontime',
|
||||||
|
version: '4.5.0',
|
||||||
|
editorKey: null,
|
||||||
|
operatorKey: null,
|
||||||
|
timeFormat: '24',
|
||||||
|
language: 'en',
|
||||||
|
});
|
||||||
|
expect(result.urlPresets).toMatchObject(demoDb.urlPresets);
|
||||||
|
expect(result.viewSettings).toMatchObject(demoDb.viewSettings);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ export function getCustomFieldData(
|
|||||||
// we lower case the excel key to make it easier to match
|
// we lower case the excel key to make it easier to match
|
||||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||||
|
const maybeExistingType = existingCustomFields[keyInCustomFields]?.type ?? 'text';
|
||||||
|
|
||||||
// 1. add the custom field to the merged custom fields
|
// 1. add the custom field to the merged custom fields
|
||||||
mergedCustomFields[keyInCustomFields] = {
|
mergedCustomFields[keyInCustomFields] = {
|
||||||
type: 'text', // we currently only support text custom fields
|
type: maybeExistingType,
|
||||||
colour: maybeExistingColour,
|
colour: maybeExistingColour,
|
||||||
label: ontimeLabel,
|
label: ontimeLabel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
isOntimeMilestone,
|
isOntimeMilestone,
|
||||||
OntimeMilestone,
|
OntimeMilestone,
|
||||||
OntimeGroup,
|
OntimeGroup,
|
||||||
|
Day,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
isObjectEmpty,
|
isObjectEmpty,
|
||||||
@@ -281,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
|
|||||||
sanitiseCustomFields(customFields, currentEntry);
|
sanitiseCustomFields(customFields, currentEntry);
|
||||||
|
|
||||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||||
currentEntry.dayOffset = processedData.totalDays;
|
currentEntry.dayOffset = processedData.totalDays as Day;
|
||||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||||
currentEntry.parent = childOfGroup;
|
currentEntry.parent = childOfGroup;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js';
|
|||||||
import { password, routerPrefix } from '../../externals.js';
|
import { password, routerPrefix } from '../../externals.js';
|
||||||
import { hashPassword } from '../../utils/hash.js';
|
import { hashPassword } from '../../utils/hash.js';
|
||||||
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
|
||||||
|
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||||
|
|
||||||
const startedAt = new Date();
|
const startedAt = new Date();
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ export async function getSessionStats(): Promise<SessionStats> {
|
|||||||
* Adds business logic to gathering data for the info endpoint
|
* Adds business logic to gathering data for the info endpoint
|
||||||
*/
|
*/
|
||||||
export async function getInfo(): Promise<GetInfo> {
|
export async function getInfo(): Promise<GetInfo> {
|
||||||
const { version, serverPort } = getDataProvider().getSettings();
|
const { version } = getDataProvider().getSettings();
|
||||||
|
const { port } = portManager.getPort();
|
||||||
|
|
||||||
// get nif and inject localhost
|
// get nif and inject localhost
|
||||||
const ni = getNetworkInterfaces();
|
const ni = getNetworkInterfaces();
|
||||||
@@ -46,7 +48,7 @@ export async function getInfo(): Promise<GetInfo> {
|
|||||||
return {
|
return {
|
||||||
networkInterfaces: ni,
|
networkInterfaces: ni,
|
||||||
version,
|
version,
|
||||||
serverPort,
|
serverPort: port,
|
||||||
publicDir: publicDir.root,
|
publicDir: publicDir.root,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ describe('parseSettings()', () => {
|
|||||||
expect(result).toBeTypeOf('object');
|
expect(result).toBeTypeOf('object');
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
version: expect.any(String),
|
version: expect.any(String),
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
version: defaultSettings.version,
|
version: defaultSettings.version,
|
||||||
serverPort: data.settings.serverPort ?? defaultSettings.serverPort,
|
|
||||||
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
|
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
|
||||||
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
||||||
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import express from 'express';
|
import express from "express";
|
||||||
import { matchedData } from 'express-validator';
|
import { matchedData } from "express-validator";
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from "express";
|
||||||
import { deepEqual } from 'fast-equals';
|
import { deepEqual } from "fast-equals";
|
||||||
|
|
||||||
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
|
import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types";
|
||||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
import { getErrorMessage, obfuscate } from "ontime-utils";
|
||||||
|
|
||||||
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
import {
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
validateSettings,
|
||||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
validateWelcomeDialog,
|
||||||
import { isDocker } from '../../setup/environment.js';
|
validateServerPort,
|
||||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
} from "./settings.validation.js";
|
||||||
|
import { getDataProvider } from "../../classes/data-provider/DataProvider.js";
|
||||||
|
import * as appState from "../../services/app-state-service/AppStateService.js";
|
||||||
|
import { sendRefetch } from "../../adapters/WebsocketAdapter.js";
|
||||||
|
import { portManager } from "../../classes/port-manager/PortManager.js";
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => {
|
router.post("/welcomedialog", validateWelcomeDialog, async (req: Request, res: Response) => {
|
||||||
const show = await appState.setShowWelcomeDialog(req.body.show);
|
const show = await appState.setShowWelcomeDialog(req.body.show);
|
||||||
res.status(200).json({ show });
|
res.status(200).json({ show });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/', (_req: Request, res: Response<Settings>) => {
|
router.get("/", (_req: Request, res: Response<Settings>) => {
|
||||||
const settings = getDataProvider().getSettings();
|
const settings = getDataProvider().getSettings();
|
||||||
const obfuscatedSettings = { ...settings };
|
const obfuscatedSettings = { ...settings };
|
||||||
if (settings.editorKey) {
|
if (settings.editorKey) {
|
||||||
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
|
|||||||
res.status(200).json(obfuscatedSettings);
|
res.status(200).json(obfuscatedSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
|
router.post(
|
||||||
|
"/",
|
||||||
|
validateSettings,
|
||||||
|
async (req: Request, res: Response<Settings | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const data = matchedData<Settings>(req);
|
||||||
|
const settings = getDataProvider().getSettings();
|
||||||
|
|
||||||
|
data.version = settings.version;
|
||||||
|
|
||||||
|
if (!deepEqual(data, settings)) {
|
||||||
|
await getDataProvider().setSettings(data);
|
||||||
|
sendRefetch(RefetchKey.Settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).json({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get("/serverport", (_req: Request, res: Response<PortInfo | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const data = matchedData<Settings>(req);
|
const { port, pendingRestart } = portManager.getPort();
|
||||||
const settings = getDataProvider().getSettings();
|
res.status(200).json({ port, pendingRestart });
|
||||||
|
|
||||||
if (isDocker && settings.serverPort !== data.serverPort) {
|
|
||||||
res.status(403).json({ message: 'Can`t change port when running inside docker' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.version = settings.version;
|
|
||||||
|
|
||||||
if (!deepEqual(data, settings)) {
|
|
||||||
await getDataProvider().setSettings(data);
|
|
||||||
sendRefetch(RefetchKey.Settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json(data);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).json({ message });
|
res.status(500).json({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/serverport",
|
||||||
|
validateServerPort,
|
||||||
|
async (req: Request, res: Response<PortInfo | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const { serverPort } = matchedData<{ serverPort: number }>(req);
|
||||||
|
portManager.changePort(serverPort);
|
||||||
|
const { port, pendingRestart } = portManager.getPort();
|
||||||
|
|
||||||
|
res.status(200).json({ port, pendingRestart });
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).json({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ export const validateSettings = [
|
|||||||
pinValidator('operatorKey'),
|
pinValidator('operatorKey'),
|
||||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||||
body('language').isString().trim().notEmpty(),
|
body('language').isString().trim().notEmpty(),
|
||||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const validateServerPort = [
|
||||||
|
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|||||||
@@ -43,18 +43,24 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
|||||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
const alias = req.params.alias;
|
const alias = req.params.alias;
|
||||||
|
const currentPresets = getDataProvider().getUrlPresets();
|
||||||
|
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||||
|
if (!existingPreset) {
|
||||||
|
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedPreset: URLPreset = {
|
const updatedPreset: URLPreset = {
|
||||||
enabled: req.body.enabled,
|
enabled: req.body.enabled,
|
||||||
alias: req.body.alias,
|
alias: req.body.alias,
|
||||||
target: req.body.target,
|
target: req.body.target,
|
||||||
search: req.body.search,
|
search: req.body.search,
|
||||||
|
options: req.body.options ?? existingPreset.options,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (alias !== updatedPreset.alias) {
|
if (alias !== updatedPreset.alias) {
|
||||||
throw new Error('Changing alias is not permitted');
|
throw new Error('Changing alias is not permitted');
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPresets = getDataProvider().getUrlPresets();
|
|
||||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||||
|
|
||||||
// Update the URL presets in the data provider
|
// Update the URL presets in the data provider
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ import { oscServer } from './adapters/OscAdapter.js';
|
|||||||
import { clearUploadfolder } from './utils/upload.js';
|
import { clearUploadfolder } from './utils/upload.js';
|
||||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||||
import { timerConfig } from './setup/config.js';
|
import { timerConfig } from './setup/config.js';
|
||||||
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
|
import { getNetworkInterfaces } from './utils/network.js';
|
||||||
|
import { portManager } from './classes/port-manager/PortManager.js';
|
||||||
|
|
||||||
console.log('\n');
|
console.log('\n');
|
||||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
@@ -176,16 +177,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable
|
|||||||
*/
|
*/
|
||||||
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
|
export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
|
||||||
checkStart(OntimeStartOrder.InitServer);
|
checkStart(OntimeStartOrder.InitServer);
|
||||||
const settings = getDataProvider().getSettings();
|
|
||||||
const { serverPort: desiredPort } = settings;
|
|
||||||
|
|
||||||
expressServer = http.createServer(app);
|
|
||||||
|
|
||||||
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
// the express server must be started before the socket otherwise the on error event listener will not attach properly
|
||||||
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
expressServer = http.createServer(app);
|
||||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
const resultPort = await portManager.attachServer(expressServer);
|
||||||
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
|
||||||
|
|
||||||
|
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
||||||
socket.init(expressServer, showWelcome, prefix);
|
socket.init(expressServer, showWelcome, prefix);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -278,6 +275,7 @@ export const shutdown = async (exitCode = 0) => {
|
|||||||
// 99 means there was a shutdown request from the UI
|
// 99 means there was a shutdown request from the UI
|
||||||
if (exitCode === 0 || exitCode === 99) {
|
if (exitCode === 0 || exitCode === 99) {
|
||||||
await restoreService.clear();
|
await restoreService.clear();
|
||||||
|
await portManager.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
expressServer?.close();
|
expressServer?.close();
|
||||||
|
|||||||
@@ -72,14 +72,12 @@ describe('safeMerge', () => {
|
|||||||
it('merges the settings key', () => {
|
it('merges the settings key', () => {
|
||||||
const mergedData = safeMerge(baseDb, {
|
const mergedData = safeMerge(baseDb, {
|
||||||
settings: {
|
settings: {
|
||||||
serverPort: 3000,
|
|
||||||
language: 'pt',
|
language: 'pt',
|
||||||
version: 'new',
|
version: 'new',
|
||||||
} as Settings,
|
} as Settings,
|
||||||
});
|
});
|
||||||
expect(mergedData.settings).toStrictEqual({
|
expect(mergedData.settings).toStrictEqual({
|
||||||
version: 'new',
|
version: 'new',
|
||||||
serverPort: 3000,
|
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
timeFormat: baseDb.settings.timeFormat,
|
timeFormat: baseDb.settings.timeFormat,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Server } from "http";
|
||||||
|
import { config } from "../../setup/config.js";
|
||||||
|
import { envPort, isDocker, isOntimeCloud } from "../../setup/environment.js";
|
||||||
|
import * as appState from "../../services/app-state-service/AppStateService.js";
|
||||||
|
import { logger } from "../Logger.js";
|
||||||
|
import { LogOrigin, MaybeNumber } from "ontime-types";
|
||||||
|
import { isAddressInfo, isPortInUseError } from "./PortManager.utils.js";
|
||||||
|
import { shouldCrashDev } from "../../utils/development.js";
|
||||||
|
|
||||||
|
class PortManager {
|
||||||
|
private static port: number;
|
||||||
|
private static pendingRestart = false;
|
||||||
|
private static newPort: MaybeNumber = null;
|
||||||
|
|
||||||
|
public getPort() {
|
||||||
|
return {
|
||||||
|
port: PortManager.port,
|
||||||
|
pendingRestart: PortManager.pendingRestart,
|
||||||
|
newPort: PortManager.newPort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* marks that a port change is requested and will be applied on next restart
|
||||||
|
* @throws if trying to change port inside docker
|
||||||
|
* @param newPort
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
public changePort(newPort: number): void {
|
||||||
|
if (isDocker) throw new Error("Can not change port when running inside docker");
|
||||||
|
if (PortManager.port === newPort) return;
|
||||||
|
PortManager.newPort = newPort;
|
||||||
|
PortManager.pendingRestart = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public migratePortFromProjectFile(port: number) {
|
||||||
|
shouldCrashDev(
|
||||||
|
PortManager.port !== undefined,
|
||||||
|
"this function should not be called after `PortManager.port` has been initialized",
|
||||||
|
);
|
||||||
|
appState.setServerPort(port);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async shutdown() {
|
||||||
|
if (PortManager.pendingRestart && PortManager.newPort != null) {
|
||||||
|
logger.info(
|
||||||
|
LogOrigin.Server,
|
||||||
|
`A port change to ${PortManager.newPort} is pending and will take effect on next start`,
|
||||||
|
);
|
||||||
|
await appState.setServerPort(PortManager.newPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to a random port assigned by the OS
|
||||||
|
* @param {http.Server} server http server object
|
||||||
|
* @returns {Promise<number>} the resulting port number
|
||||||
|
* @throws any other server errors will result in a throw
|
||||||
|
*/
|
||||||
|
public async attachServer(server: Server): Promise<number> {
|
||||||
|
if (isOntimeCloud) {
|
||||||
|
PortManager.port = await this.forceCloudPort(server);
|
||||||
|
} else {
|
||||||
|
PortManager.port =
|
||||||
|
this.parsePort(envPort) || (await appState.getServerPort()) || config.defaultServerPort;
|
||||||
|
PortManager.port = await this.tryServerPort(server);
|
||||||
|
}
|
||||||
|
await appState.setServerPort(PortManager.port);
|
||||||
|
return PortManager.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePort(port: string | undefined) {
|
||||||
|
if (typeof port !== "string") return null;
|
||||||
|
if (port === "") return null;
|
||||||
|
const maybePort = Number(port);
|
||||||
|
if (isNaN(maybePort)) return null;
|
||||||
|
return maybePort;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async tryServerPort(server: Server): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", (error) => {
|
||||||
|
// we should only move ports if we are in a desktop environment
|
||||||
|
if (isDocker) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPortInUseError(error)) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we get an address in use error, we will try to open the server in an ephemeral port
|
||||||
|
// port 0 will assign an ephemeral port
|
||||||
|
server.listen(0, "0.0.0.0", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!isAddressInfo(address)) {
|
||||||
|
reject(new Error("Unknown port type, unable to proceed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error(
|
||||||
|
LogOrigin.Server,
|
||||||
|
`Failed to open the desired port: ${PortManager.port} \nMoved to an Ephemeral port: ${address.port}`,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PortManager.port, "0.0.0.0", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!isAddressInfo(address)) {
|
||||||
|
reject(new Error("Unknown port type, unable to proceed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private forceCloudPort(server: Server): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
server.listen(config.defaultServerPort, "0.0.0.0", () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!isAddressInfo(address)) {
|
||||||
|
reject(new Error("Unknown port type, unable to proceed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portManager = new PortManager();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { AddressInfo } from 'net';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a given error is a port in use error
|
||||||
|
*/
|
||||||
|
export function isPortInUseError(err: Error): boolean {
|
||||||
|
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard verifies that the given address is a usable AddressInfo object
|
||||||
|
*/
|
||||||
|
export function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
|
||||||
|
return typeof address === 'object' && address !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, millisToString } from 'ontime-utils';
|
||||||
|
import { Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||||
|
|
||||||
|
import { timeNow } from '../../../utils/time.js';
|
||||||
|
import * as timeCore from '../timeCore.js';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// TZ is set to Europe/Copenhagen in vitest.global-setup.ts
|
||||||
|
// Copenhagen is UTC+1 (CET) in winter and UTC+2 (CEST) in summer
|
||||||
|
|
||||||
|
describe('toTimeofDay() converts an instant to local milliseconds since midnight', () => {
|
||||||
|
const testTimes = [
|
||||||
|
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
|
||||||
|
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
|
||||||
|
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
|
||||||
|
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
|
||||||
|
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
|
||||||
|
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(testTimes)('produces the correct local time for $label ($time)', ({ time }) => {
|
||||||
|
vi.setSystemTime(time);
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(result).toBe(timeNow());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a value in the range [0, dayInMs)', () => {
|
||||||
|
vi.setSystemTime('2025-06-15T23:59:59.999Z');
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(result).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result).toBeLessThan(dayInMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handles DST transitions in Europe/Copenhagen', () => {
|
||||||
|
it('produces CET time just before spring forward', () => {
|
||||||
|
// 2025-03-30 at 02:00 CET clocks jump to 03:00 CEST
|
||||||
|
// UTC 00:58:18 → Copenhagen CET (UTC+1) → local 01:58:18
|
||||||
|
vi.setSystemTime('2025-03-30T00:58:18Z');
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(millisToString(result)).toBe('01:58:18');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces CEST time just after spring forward', () => {
|
||||||
|
// UTC 01:00:00 → Copenhagen CEST (UTC+2) → local 03:00:00
|
||||||
|
// 02:00 local does not exist, clocks skip to 03:00
|
||||||
|
vi.setSystemTime('2025-03-30T01:00:00Z');
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(millisToString(result)).toBe('03:00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces CEST time just before fall back', () => {
|
||||||
|
// 2025-10-26 at 03:00 CEST clocks fall back to 02:00 CET
|
||||||
|
// UTC 00:59:59 → Copenhagen CEST (UTC+2) → local 02:59:59
|
||||||
|
vi.setSystemTime('2025-10-26T00:59:59Z');
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(millisToString(result)).toBe('02:59:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces CET time just after fall back', () => {
|
||||||
|
// UTC 01:00:00 → Copenhagen CET (UTC+1) → local 02:00:00
|
||||||
|
vi.setSystemTime('2025-10-26T01:00:00Z');
|
||||||
|
|
||||||
|
const result = timeCore.toTimeOfDay(timeCore.now());
|
||||||
|
expect(millisToString(result)).toBe('02:00:00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toInstant() converts a time of day back to an instant anchored to a reference day', () => {
|
||||||
|
const testTimes = [
|
||||||
|
{ time: '2025-01-15T08:30:00Z', label: 'winter morning' },
|
||||||
|
{ time: '2025-06-15T14:00:00Z', label: 'summer afternoon' },
|
||||||
|
{ time: '2025-01-01T00:00:00Z', label: 'midnight UTC on new years' },
|
||||||
|
{ time: '2025-07-01T23:59:59Z', label: 'just before midnight UTC in summer' },
|
||||||
|
{ time: '2025-03-15T12:00:00Z', label: 'noon UTC in winter' },
|
||||||
|
{ time: '2025-09-15T12:00:00Z', label: 'noon UTC in summer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(testTimes)('roundtrips through toTimeofDay for $label ($time)', ({ time }) => {
|
||||||
|
vi.setSystemTime(time);
|
||||||
|
|
||||||
|
const instant = timeCore.now();
|
||||||
|
const clock = timeCore.toTimeOfDay(instant);
|
||||||
|
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('roundtrips through DST transitions in Europe/Copenhagen', () => {
|
||||||
|
it('roundtrips just before spring forward', () => {
|
||||||
|
vi.setSystemTime('2025-03-30T00:58:18Z');
|
||||||
|
|
||||||
|
const instant = timeCore.now();
|
||||||
|
const clock = timeCore.toTimeOfDay(instant);
|
||||||
|
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips just after spring forward', () => {
|
||||||
|
vi.setSystemTime('2025-03-30T01:00:00Z');
|
||||||
|
|
||||||
|
const instant = timeCore.now();
|
||||||
|
const clock = timeCore.toTimeOfDay(instant);
|
||||||
|
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips just before fall back', () => {
|
||||||
|
vi.setSystemTime('2025-10-26T00:59:59Z');
|
||||||
|
|
||||||
|
const instant = timeCore.now();
|
||||||
|
const clock = timeCore.toTimeOfDay(instant);
|
||||||
|
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('roundtrips just after fall back', () => {
|
||||||
|
vi.setSystemTime('2025-10-26T01:00:00Z');
|
||||||
|
|
||||||
|
const instant = timeCore.now();
|
||||||
|
const clock = timeCore.toTimeOfDay(instant);
|
||||||
|
expect(timeCore.toInstant(clock, instant)).toBe(instant);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeSince() returns the duration elapsed since a past point', () => {
|
||||||
|
it('measures elapsed time between two instants', () => {
|
||||||
|
const start = 1000 as Instant;
|
||||||
|
const end = 5000 as Instant;
|
||||||
|
expect(timeCore.timeSince(end, start)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns negative when the reference is in the future', () => {
|
||||||
|
const start = 5000 as Instant;
|
||||||
|
const end = 1000 as Instant;
|
||||||
|
expect(timeCore.timeSince(end, start)).toBe(-4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('timeUntil() returns the duration until a future point', () => {
|
||||||
|
it('measures time remaining until a future instant', () => {
|
||||||
|
const current = 1000 as Instant;
|
||||||
|
const target = 5000 as Instant;
|
||||||
|
expect(timeCore.timeUntil(current, target)).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns negative when the target is in the past', () => {
|
||||||
|
const current = 5000 as Instant;
|
||||||
|
const target = 1000 as Instant;
|
||||||
|
expect(timeCore.timeUntil(current, target)).toBe(-4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addDuration() moves a point in time by a duration', () => {
|
||||||
|
it('moves an instant forward', () => {
|
||||||
|
const instant = 1000 as Instant;
|
||||||
|
const duration = 500 as Duration;
|
||||||
|
expect(timeCore.addDuration(instant, duration)).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves backward with a negative duration', () => {
|
||||||
|
const instant = 1000 as Instant;
|
||||||
|
const duration = -300 as Duration;
|
||||||
|
expect(timeCore.addDuration(instant, duration)).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves by the sum of multiple durations', () => {
|
||||||
|
const instant = 1000 as Instant;
|
||||||
|
const durations = [500, -300, 50] as Duration[];
|
||||||
|
expect(timeCore.addDuration(instant, durations)).toBe(1250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the instant unchanged with an empty duration list', () => {
|
||||||
|
const instant = 1000 as Instant;
|
||||||
|
expect(timeCore.addDuration(instant, [])).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('elapsedTime() calculates duration between two times of day', () => {
|
||||||
|
it('calculates elapsed time on the same day', () => {
|
||||||
|
const start = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
|
||||||
|
const clock = (10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:30
|
||||||
|
expect(timeCore.elapsedTime(clock, start)).toBe(30 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates elapsed time when crossing midnight (overnight)', () => {
|
||||||
|
const start = (23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:50
|
||||||
|
const clock = (21 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:21
|
||||||
|
// From 23:50 to 00:21 = 31 minutes
|
||||||
|
expect(timeCore.elapsedTime(clock, start)).toBe(31 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when start and clock are the same', () => {
|
||||||
|
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
|
||||||
|
expect(timeCore.elapsedTime(time, time)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates correctly for just after midnight', () => {
|
||||||
|
const start = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
||||||
|
const clock = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
||||||
|
// From 23:59 to 00:01 = 2 minutes
|
||||||
|
expect(timeCore.elapsedTime(clock, start)).toBe(2 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daysSinceStart() calculates full days elapsed since a start epoch', () => {
|
||||||
|
it('returns 0 when current epoch equals start epoch', () => {
|
||||||
|
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||||
|
const epoch = timeCore.now();
|
||||||
|
expect(timeCore.daysSinceStart(epoch, epoch)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when less than one day has elapsed', () => {
|
||||||
|
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||||
|
const startEpoch = timeCore.now();
|
||||||
|
|
||||||
|
vi.setSystemTime('2025-01-15T18:00:00Z'); // 8 hours later
|
||||||
|
const currentEpoch = timeCore.now();
|
||||||
|
|
||||||
|
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 1 when crossing midnight once', () => {
|
||||||
|
// Copenhagen is UTC+1 in winter, so 22:50 UTC = 23:50 local
|
||||||
|
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||||
|
const startEpoch = timeCore.now();
|
||||||
|
|
||||||
|
vi.setSystemTime('2025-01-15T23:21:00Z'); // 00:21 local next day
|
||||||
|
const currentEpoch = timeCore.now();
|
||||||
|
|
||||||
|
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 2 when crossing midnight twice', () => {
|
||||||
|
vi.setSystemTime('2025-01-15T10:00:00Z');
|
||||||
|
const startEpoch = timeCore.now();
|
||||||
|
|
||||||
|
vi.setSystemTime('2025-01-17T15:00:00Z'); // 2 days + 5 hours later
|
||||||
|
const currentEpoch = timeCore.now();
|
||||||
|
|
||||||
|
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles overnight start correctly', () => {
|
||||||
|
// Copenhagen is UTC+1 in winter
|
||||||
|
// Start at 23:50 local (22:50 UTC), check at 00:10 local next day (23:10 UTC)
|
||||||
|
vi.setSystemTime('2025-01-15T22:50:00Z'); // 23:50 local
|
||||||
|
const startEpoch = timeCore.now();
|
||||||
|
|
||||||
|
vi.setSystemTime('2025-01-15T23:10:00Z'); // 00:10 local next day
|
||||||
|
const currentEpoch = timeCore.now();
|
||||||
|
|
||||||
|
expect(timeCore.daysSinceStart(startEpoch, currentEpoch)).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Day, Duration, Instant, TimeOfDay } from 'ontime-types';
|
||||||
|
import { dayInMs, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
|
/** Returns the current instant */
|
||||||
|
export function now(): Instant {
|
||||||
|
return Date.now() as Instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an instant to milliseconds since midnight in the local timezone
|
||||||
|
* - Accounts for the system's timezone offset including DST
|
||||||
|
* - Result is always in the range [0, dayInMs)
|
||||||
|
*/
|
||||||
|
export function toTimeOfDay(instant: Instant): TimeOfDay {
|
||||||
|
const tzOffset = new Date(instant).getTimezoneOffset() * MILLIS_PER_MINUTE;
|
||||||
|
return ((((instant - tzOffset) % dayInMs) + dayInMs) % dayInMs) as TimeOfDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current time of day in milliseconds since midnight
|
||||||
|
*/
|
||||||
|
export function timeOfDayNow(): TimeOfDay {
|
||||||
|
return toTimeOfDay(now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a time of day to an instant anchored to the same day as the reference
|
||||||
|
* - Uses the reference instant to determine which calendar day to anchor to
|
||||||
|
*/
|
||||||
|
export function toInstant(clock: TimeOfDay, reference: Instant): Instant {
|
||||||
|
const referenceClock = toTimeOfDay(reference);
|
||||||
|
const dayStart = reference - referenceClock;
|
||||||
|
return (dayStart + clock) as Instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the duration elapsed since a past instant
|
||||||
|
* Result is positive when 'since' is before 'now'
|
||||||
|
*/
|
||||||
|
export function timeSince(now: Instant, since: Instant): Duration {
|
||||||
|
return (now - since) as Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the duration remaining until a future instant
|
||||||
|
* Result is positive when 'until' is after 'now'
|
||||||
|
*/
|
||||||
|
export function timeUntil(now: Instant, until: Instant): Duration {
|
||||||
|
return (until - now) as Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an instant forward or backward by a duration
|
||||||
|
* Use a negative duration to move backward
|
||||||
|
*/
|
||||||
|
export function addDuration(instant: Instant, duration: Duration | Duration[]): Instant {
|
||||||
|
const totalDuration = Array.isArray(duration) ? duration.reduce((total, current) => total + current, 0) : duration;
|
||||||
|
|
||||||
|
return (instant + totalDuration) as Instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates elapsed time on the clock from a starting time to the current time
|
||||||
|
* Handles overnight crossing (when current < start, assumes we've crossed midnight)
|
||||||
|
*/
|
||||||
|
export function elapsedTime(current: TimeOfDay, start: TimeOfDay): Duration {
|
||||||
|
return (current < start ? current + dayInMs - start : current - start) as Duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the number of full days elapsed since a start epoch
|
||||||
|
* Uses the start time-of-day to determine day boundaries
|
||||||
|
*/
|
||||||
|
export function daysSinceStart(startEpoch: Instant, currentEpoch: Instant): Day {
|
||||||
|
const startClock = toTimeOfDay(startEpoch);
|
||||||
|
const elapsedMs = currentEpoch - startEpoch;
|
||||||
|
return Math.floor((elapsedMs + startClock) / dayInMs) as Day;
|
||||||
|
}
|
||||||
@@ -25,7 +25,6 @@ const dbModel: DatabaseModel = {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
version: ONTIME_VERSION,
|
version: ONTIME_VERSION,
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
@@ -41,7 +40,7 @@ const dbModel: DatabaseModel = {
|
|||||||
customFields: {},
|
customFields: {},
|
||||||
automation: {
|
automation: {
|
||||||
enabledAutomations: true,
|
enabledAutomations: true,
|
||||||
enabledOscIn: true,
|
enabledOscIn: false,
|
||||||
oscPortIn: 8888,
|
oscPortIn: 8888,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
automations: {},
|
automations: {},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DatabaseModel, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
import { DatabaseModel, Day, EndAction, OntimeView, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
export const demoDb: DatabaseModel = {
|
export const demoDb: DatabaseModel = {
|
||||||
rundowns: {
|
rundowns: {
|
||||||
@@ -66,7 +66,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Music plays, holding slide on screens',
|
note: 'Music plays, holding slide on screens',
|
||||||
colour: '#77C785',
|
colour: '#77C785',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '1',
|
cue: '1',
|
||||||
parent: '7eaf99',
|
parent: '7eaf99',
|
||||||
@@ -109,7 +109,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Emma Thompson',
|
note: 'Emma Thompson',
|
||||||
colour: '#FFCC78',
|
colour: '#FFCC78',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '1.1',
|
cue: '1.1',
|
||||||
parent: '7eaf99',
|
parent: '7eaf99',
|
||||||
@@ -141,7 +141,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Liam Carter, Sophia Patel + PowerPoint',
|
note: 'Liam Carter, Sophia Patel + PowerPoint',
|
||||||
colour: '#77C785',
|
colour: '#77C785',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '1.2',
|
cue: '1.2',
|
||||||
parent: '7eaf99',
|
parent: '7eaf99',
|
||||||
@@ -199,7 +199,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Buffet in lobby',
|
note: 'Buffet in lobby',
|
||||||
colour: '#779BE7',
|
colour: '#779BE7',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '2.1',
|
cue: '2.1',
|
||||||
parent: 'f60403',
|
parent: 'f60403',
|
||||||
@@ -257,7 +257,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Ethan Brooks + PowerPoint + Video playback',
|
note: 'Ethan Brooks + PowerPoint + Video playback',
|
||||||
colour: '#77C785',
|
colour: '#77C785',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '3.1',
|
cue: '3.1',
|
||||||
parent: '6b0edb',
|
parent: '6b0edb',
|
||||||
@@ -289,7 +289,7 @@ export const demoDb: DatabaseModel = {
|
|||||||
note: 'Lucas Bennett',
|
note: 'Lucas Bennett',
|
||||||
colour: '#FFCC78',
|
colour: '#FFCC78',
|
||||||
delay: 0,
|
delay: 0,
|
||||||
dayOffset: 0,
|
dayOffset: 0 as Day,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
cue: '3.2',
|
cue: '3.2',
|
||||||
parent: '6b0edb',
|
parent: '6b0edb',
|
||||||
@@ -336,7 +336,6 @@ export const demoDb: DatabaseModel = {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
version: '-',
|
version: '-',
|
||||||
serverPort: 4001,
|
|
||||||
editorKey: null,
|
editorKey: null,
|
||||||
operatorKey: null,
|
operatorKey: null,
|
||||||
timeFormat: '24',
|
timeFormat: '24',
|
||||||
|
|||||||
@@ -709,6 +709,44 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
|
|||||||
expect(state).toStrictEqual(expected);
|
expect(state).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should recognise a playing event where its schedule spans over midnight, even with previous events', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
await initRundown(
|
||||||
|
makeRundown({
|
||||||
|
order: ['1', '2'],
|
||||||
|
entries: {
|
||||||
|
'1': {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 10 * MILLIS_PER_HOUR,
|
||||||
|
timeEnd: 11 * MILLIS_PER_HOUR,
|
||||||
|
duration: 1 * MILLIS_PER_HOUR,
|
||||||
|
},
|
||||||
|
'2': {
|
||||||
|
...mockEvent,
|
||||||
|
id: '2',
|
||||||
|
timeStart: 11 * MILLIS_PER_HOUR,
|
||||||
|
timeEnd: 2 * MILLIS_PER_HOUR,
|
||||||
|
duration: 14 * MILLIS_PER_HOUR
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
vi.runAllTimers();
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
const now = 1 * MILLIS_PER_HOUR;
|
||||||
|
const expected = {
|
||||||
|
event: rundown.entries['2'],
|
||||||
|
index: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = loadRoll(rundown, metadata, now);
|
||||||
|
expect(state).toStrictEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('if the start time is the day after end time, and both are later than now', async () => {
|
it('if the start time is the day after end time, and both are later than now', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
await initRundown(
|
await initRundown(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils';
|
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||||
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
|
hasCrossedMidnight,
|
||||||
normaliseEndTime,
|
normaliseEndTime,
|
||||||
skippedOutOfEvent,
|
skippedOutOfEvent,
|
||||||
} from '../timerUtils.js';
|
} from '../timerUtils.js';
|
||||||
@@ -537,6 +538,25 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasCrossedMidnight()', () => {
|
||||||
|
it('returns true when clock wraps from late to early', () => {
|
||||||
|
const previous = (23 * MILLIS_PER_HOUR + 59 * MILLIS_PER_MINUTE) as TimeOfDay; // 23:59
|
||||||
|
const current = (1 * MILLIS_PER_MINUTE) as TimeOfDay; // 00:01
|
||||||
|
expect(hasCrossedMidnight(previous, current)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when clock moves forward on the same day', () => {
|
||||||
|
const previous = (10 * MILLIS_PER_HOUR) as TimeOfDay; // 10:00
|
||||||
|
const current = (10 * MILLIS_PER_HOUR + 5 * MILLIS_PER_MINUTE) as TimeOfDay; // 10:05
|
||||||
|
expect(hasCrossedMidnight(previous, current)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when clock value is unchanged', () => {
|
||||||
|
const time = (15 * MILLIS_PER_HOUR) as TimeOfDay; // 15:00
|
||||||
|
expect(hasCrossedMidnight(time, time)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('skippedOutOfEvent()', () => {
|
describe('skippedOutOfEvent()', () => {
|
||||||
const testSkipLimit = 32;
|
const testSkipLimit = 32;
|
||||||
it('does not consider an event end as a skip', () => {
|
it('does not consider an event end as a skip', () => {
|
||||||
@@ -693,6 +713,44 @@ describe('skippedOutOfEvent()', () => {
|
|||||||
state.clock -= testSkipLimit + 1;
|
state.clock -= testSkipLimit + 1;
|
||||||
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
expect(skippedOutOfEvent(state, previousTime, testSkipLimit)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles events that cross midnight', () => {
|
||||||
|
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
||||||
|
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
||||||
|
const clock = 14 * MILLIS_PER_MINUTE + 47 * MILLIS_PER_SECOND; // 00:14:47
|
||||||
|
const previousTime = clock - 1021; // ~1 second ago
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
clock,
|
||||||
|
timer: {
|
||||||
|
expectedFinish,
|
||||||
|
startedAt,
|
||||||
|
},
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
// Even though clock < startedAt numerically (00:14 < 23:50),
|
||||||
|
// we're inside the overnight event, so this should NOT be a skip
|
||||||
|
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly detects skip out of an event that crosses midnight', () => {
|
||||||
|
// Event 23:50-01:50, clock jumps to 02:00 (outside event)
|
||||||
|
const startedAt = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
||||||
|
const expectedFinish = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
||||||
|
const previousTime = 1 * MILLIS_PER_HOUR + 49 * MILLIS_PER_MINUTE; // 01:49
|
||||||
|
const clock = 2 * MILLIS_PER_HOUR; // 02:00 (outside event)
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
clock,
|
||||||
|
timer: {
|
||||||
|
expectedFinish,
|
||||||
|
startedAt,
|
||||||
|
},
|
||||||
|
} as RuntimeState;
|
||||||
|
|
||||||
|
// Clock jumped from 01:49 to 02:00 (11 min skip) and is now outside the event
|
||||||
|
expect(skippedOutOfEvent(state, previousTime, 1000)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normaliseEndTime()', () => {
|
test('normaliseEndTime()', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AppState {
|
|||||||
projectName?: string;
|
projectName?: string;
|
||||||
rundownId?: string;
|
rundownId?: string;
|
||||||
showWelcomeDialog?: boolean;
|
showWelcomeDialog?: boolean;
|
||||||
|
serverPort?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
||||||
@@ -61,3 +62,13 @@ export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
|
|||||||
await config.write();
|
await config.write();
|
||||||
return show;
|
return show;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getServerPort(): Promise<number | undefined> {
|
||||||
|
await config.read();
|
||||||
|
return config.data.serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setServerPort(port: number): Promise<void> {
|
||||||
|
config.data.serverPort = port;
|
||||||
|
await config.write();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { MessageState, runtimeStorePlaceholder } from 'ontime-types';
|
import { MessageState, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
|
import { withoutUndefinedValues } from 'ontime-utils';
|
||||||
import { DeepPartial } from 'ts-essentials';
|
import { DeepPartial } from 'ts-essentials';
|
||||||
|
|
||||||
import { throttle } from '../../utils/throttle.js';
|
import { throttle } from '../../utils/throttle.js';
|
||||||
import type { StoreGetter, PublishFn } from '../../stores/EventStore.js';
|
import type { StoreGetter, PublishFn } from '../../stores/EventStore.js';
|
||||||
import { withoutUndefinedValues } from '../../../../../packages/utils/src/common/objectUtils.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a throttled version of the set function
|
* Create a throttled version of the set function
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ export async function initialiseProject(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projectName = await loadProjectFile(lastLoaded.projectName, lastLoaded.rundownId);
|
const projectName = await loadProjectFile(lastLoaded.projectName, {
|
||||||
|
rundownId: lastLoaded.rundownId,
|
||||||
|
initialLoad: true,
|
||||||
|
});
|
||||||
return projectName;
|
return projectName;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// if we are here, most likely the json parsing failed and the file is corrupt
|
// if we are here, most likely the json parsing failed and the file is corrupt
|
||||||
@@ -207,7 +210,10 @@ export async function initialiseProject(): Promise<string> {
|
|||||||
* @throws
|
* @throws
|
||||||
* @param fileName file name of the project including the extension
|
* @param fileName file name of the project including the extension
|
||||||
*/
|
*/
|
||||||
export async function loadProjectFile(fileName: string, rundownId?: string): Promise<string> {
|
export async function loadProjectFile(
|
||||||
|
fileName: string,
|
||||||
|
options?: { rundownId?: string; initialLoad?: boolean },
|
||||||
|
): Promise<string> {
|
||||||
const filePath = doesProjectExist(fileName);
|
const filePath = doesProjectExist(fileName);
|
||||||
if (filePath === null) {
|
if (filePath === null) {
|
||||||
throw new Error('Project file not found');
|
throw new Error('Project file not found');
|
||||||
@@ -215,7 +221,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro
|
|||||||
|
|
||||||
// when loading a project file, we allow parsing to fail and interrupt the process
|
// when loading a project file, we allow parsing to fail and interrupt the process
|
||||||
const fileData = await parseJsonFile(filePath);
|
const fileData = await parseJsonFile(filePath);
|
||||||
const result = parseDatabaseModel(fileData);
|
const result = parseDatabaseModel(fileData, options?.initialLoad);
|
||||||
let parsedFileName = fileName;
|
let parsedFileName = fileName;
|
||||||
|
|
||||||
if (result.migrated) {
|
if (result.migrated) {
|
||||||
@@ -226,7 +232,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro
|
|||||||
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
|
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectName = await loadProject(result.data, parsedFileName, rundownId);
|
const projectName = await loadProject(result.data, parsedFileName, options?.rundownId);
|
||||||
return projectName;
|
return projectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Playback, MaybeString, MaybeNumber } from 'ontime-types';
|
import { Playback, MaybeString, MaybeNumber, Maybe, Instant } from 'ontime-types';
|
||||||
|
|
||||||
export type RestorePoint = {
|
export type RestorePoint = {
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
@@ -7,6 +7,6 @@ export type RestorePoint = {
|
|||||||
addedTime: number;
|
addedTime: number;
|
||||||
pausedAt: MaybeNumber;
|
pausedAt: MaybeNumber;
|
||||||
firstStart: MaybeNumber;
|
firstStart: MaybeNumber;
|
||||||
startEpoch: MaybeNumber;
|
startEpoch: Maybe<Instant>;
|
||||||
currentDay: MaybeNumber;
|
currentDay: MaybeNumber;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { dayInMs } from 'ontime-utils';
|
import { checkIsNow, dayInMs } from 'ontime-utils';
|
||||||
import { MaybeNumber, PlayableEvent, Rundown } from 'ontime-types';
|
import { MaybeNumber, PlayableEvent, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
import { normaliseEndTime } from './timerUtils.js';
|
import { normaliseEndTime } from './timerUtils.js';
|
||||||
@@ -36,11 +36,6 @@ export function loadRoll(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we check if event crosses midnight
|
|
||||||
if (event.timeStart > event.timeEnd) {
|
|
||||||
daySpan++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const correctedDays = dayInMs * daySpan;
|
const correctedDays = dayInMs * daySpan;
|
||||||
const correctedStart = event.timeStart + correctedDays;
|
const correctedStart = event.timeStart + correctedDays;
|
||||||
const correctedEnd = event.timeEnd + correctedDays;
|
const correctedEnd = event.timeEnd + correctedDays;
|
||||||
@@ -49,13 +44,18 @@ export function loadRoll(
|
|||||||
* there are 3 possible states for an event
|
* there are 3 possible states for an event
|
||||||
* 1. event is already finished
|
* 1. event is already finished
|
||||||
* 2. event is running
|
* 2. event is running
|
||||||
* 3. event is in the future
|
* 3. event is running (midnight edge case)
|
||||||
|
* 4. event is in the future
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 1. event is already finished
|
// 1. event is already finished
|
||||||
// when does the event end (handle midnight)
|
// when does the event end (handle midnight)
|
||||||
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
|
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
|
||||||
if (normalEnd <= timeNow) {
|
if (normalEnd <= timeNow) {
|
||||||
|
// keep track of day progress
|
||||||
|
if (event.timeStart > event.timeEnd) {
|
||||||
|
daySpan++;
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +66,25 @@ export function loadRoll(
|
|||||||
return { event, index: getTimedIndexFromPlayableIndex(metadata, i) };
|
return { event, index: getTimedIndexFromPlayableIndex(metadata, i) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. event will run in the future
|
// 3. check if an overnight event that comes later is currently playing
|
||||||
// we set the isPending flag to indicate that the event is currently playing
|
for (let j = i + 1; j < metadata.playableEventOrder.length; j++) {
|
||||||
|
const futureEventId = metadata.playableEventOrder[j];
|
||||||
|
if (!futureEventId) continue;
|
||||||
|
|
||||||
|
const futureEvent = rundown.entries[futureEventId] as PlayableEvent;
|
||||||
|
if (!futureEvent || futureEvent.duration === 0) continue;
|
||||||
|
|
||||||
|
const crossesMidnight = futureEvent.timeStart > futureEvent.timeEnd;
|
||||||
|
if (!crossesMidnight) continue;
|
||||||
|
|
||||||
|
// check if timeNow is inside this overnight event
|
||||||
|
const isRunning = checkIsNow(futureEvent.timeStart, futureEvent.timeEnd, timeNow);
|
||||||
|
if (isRunning) {
|
||||||
|
return { event: futureEvent, index: getTimedIndexFromPlayableIndex(metadata, j) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. event will run in the future
|
||||||
return { event, index: getTimedIndexFromPlayableIndex(metadata, i), isPending: true };
|
return { event, index: getTimedIndexFromPlayableIndex(metadata, i), isPending: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ class RuntimeService {
|
|||||||
if (timerPhaseChanged) {
|
if (timerPhaseChanged) {
|
||||||
if (newState.timer.phase === TimerPhase.Warning) {
|
if (newState.timer.phase === TimerPhase.Warning) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onWarning, newState);
|
triggerAutomations(TimerLifeCycle.onWarning);
|
||||||
});
|
});
|
||||||
} else if (newState.timer.phase === TimerPhase.Danger) {
|
} else if (newState.timer.phase === TimerPhase.Danger) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onDanger, newState);
|
triggerAutomations(TimerLifeCycle.onDanger);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ class RuntimeService {
|
|||||||
} else if (hasTimerFinished) {
|
} else if (hasTimerFinished) {
|
||||||
// if the timer has finished, we need to load next and keep rolling
|
// if the timer has finished, we need to load next and keep rolling
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
triggerAutomations(TimerLifeCycle.onFinish);
|
||||||
});
|
});
|
||||||
this.handleLoadNext();
|
this.handleLoadNext();
|
||||||
this.rollLoaded(newState.offset);
|
this.rollLoaded(newState.offset);
|
||||||
@@ -116,7 +116,7 @@ class RuntimeService {
|
|||||||
// 3. find if we need to process actions related to the timer finishing
|
// 3. find if we need to process actions related to the timer finishing
|
||||||
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
|
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
triggerAutomations(TimerLifeCycle.onFinish);
|
||||||
});
|
});
|
||||||
|
|
||||||
// handle end action if there was a timer playing
|
// handle end action if there was a timer playing
|
||||||
@@ -134,7 +134,7 @@ class RuntimeService {
|
|||||||
const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current);
|
const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current);
|
||||||
if (shouldUpdateTimer) {
|
if (shouldUpdateTimer) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onUpdate, newState);
|
triggerAutomations(TimerLifeCycle.onUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
|
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
|
||||||
@@ -144,7 +144,7 @@ class RuntimeService {
|
|||||||
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
||||||
if (shouldUpdateClock) {
|
if (shouldUpdateClock) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onClock, newState);
|
triggerAutomations(TimerLifeCycle.onClock);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lastIntegrationClockUpdate = newState.clock;
|
this.lastIntegrationClockUpdate = newState.clock;
|
||||||
@@ -209,10 +209,9 @@ class RuntimeService {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||||
const newState = runtimeState.getState();
|
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
triggerAutomations(TimerLifeCycle.onLoad);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
@@ -447,7 +446,7 @@ class RuntimeService {
|
|||||||
if (didStart) {
|
if (didStart) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
triggerAutomations(TimerLifeCycle.onStart);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return didStart;
|
return didStart;
|
||||||
@@ -500,7 +499,7 @@ class RuntimeService {
|
|||||||
const newState = runtimeState.getState();
|
const newState = runtimeState.getState();
|
||||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerAutomations(TimerLifeCycle.onPause, newState);
|
triggerAutomations(TimerLifeCycle.onPause);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +519,7 @@ class RuntimeService {
|
|||||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||||
triggerAutomations(TimerLifeCycle.onStop, newState);
|
triggerAutomations(TimerLifeCycle.onStop);
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -548,7 +547,14 @@ class RuntimeService {
|
|||||||
const metadata = getRundownMetadata();
|
const metadata = getRundownMetadata();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
runtimeState.roll(rundown, metadata, offset);
|
const result = runtimeState.roll(rundown, metadata, offset);
|
||||||
|
if (result.didStart) {
|
||||||
|
const newState = runtimeState.getState();
|
||||||
|
process.nextTick(() => {
|
||||||
|
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||||
|
triggerAutomations(TimerLifeCycle.onStart);
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||||
}
|
}
|
||||||
@@ -577,14 +583,14 @@ class RuntimeService {
|
|||||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
triggerAutomations(TimerLifeCycle.onLoad);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.didStart) {
|
if (result.didStart) {
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
triggerAutomations(TimerLifeCycle.onStart);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||||
import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
import { checkIsNow, dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||||
|
|
||||||
@@ -8,6 +8,15 @@ import type { RuntimeState } from '../stores/runtimeState.js';
|
|||||||
*/
|
*/
|
||||||
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
|
export const normaliseEndTime = (start: number, end: number) => (end < start ? end + dayInMs : end);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the local wall clock wrapped into a new day
|
||||||
|
* This currently uses a simple wrap heuristic and is centralized
|
||||||
|
* so day-boundary behavior can be evolved in one place later.
|
||||||
|
*/
|
||||||
|
export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boolean {
|
||||||
|
return previous > current;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates expected finish time of a running timer
|
* Calculates expected finish time of a running timer
|
||||||
* @param {RuntimeState} state runtime state
|
* @param {RuntimeState} state runtime state
|
||||||
@@ -98,14 +107,15 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
|
|||||||
const { startedAt, expectedFinish } = state.timer;
|
const { startedAt, expectedFinish } = state.timer;
|
||||||
const { clock } = state;
|
const { clock } = state;
|
||||||
|
|
||||||
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
|
const isInsideEvent = checkIsNow(startedAt, expectedFinish, clock);
|
||||||
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
|
if (isInsideEvent) return false;
|
||||||
|
|
||||||
const timeDifference = previousTime - adjustedClock;
|
// we are outside the event, but we need to check if we skipped or just finished normally
|
||||||
const hasSkipped = Math.abs(timeDifference) > skipLimit;
|
const timeFromPrevious = Math.abs(previousTime - clock);
|
||||||
const adjustedExpectedFinish = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
|
// account for midnight when checking skips
|
||||||
|
const hasSkipped = Math.min(timeFromPrevious, dayInMs - timeFromPrevious) > skipLimit;
|
||||||
|
|
||||||
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
|
return hasSkipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,9 +197,9 @@ export function getTimerPhase(state: RuntimeState): TimerPhase {
|
|||||||
* Finds the day offset relative to an event start
|
* Finds the day offset relative to an event start
|
||||||
* used byt the runtimeState on first start to get correct offsets
|
* used byt the runtimeState on first start to get correct offsets
|
||||||
*/
|
*/
|
||||||
export function findDayOffset(plannedStart: number, clock: number): number {
|
export function findDayOffset(plannedStart: number, clock: number): Day {
|
||||||
const distance = clock - plannedStart;
|
const distance = clock - plannedStart;
|
||||||
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
|
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
|
||||||
if (distance < -12 * MILLIS_PER_HOUR) return 1;
|
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
||||||
return 0;
|
return 0 as Day;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const config = {
|
|||||||
crash: 'crash logs',
|
crash: 'crash logs',
|
||||||
demoProject: 'demo project.json',
|
demoProject: 'demo project.json',
|
||||||
newProject: 'new project.json',
|
newProject: 'new project.json',
|
||||||
|
defaultServerPort: 4001,
|
||||||
database: {
|
database: {
|
||||||
directory: 'db',
|
directory: 'db',
|
||||||
filename: 'db.json',
|
filename: 'db.json',
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ export const environment = isTest ? 'test' : env;
|
|||||||
export const isDocker = env === 'docker';
|
export const isDocker = env === 'docker';
|
||||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||||
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
|
||||||
|
|
||||||
|
export const envPort = process.env.PORT;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { TimerPhase, Playback, OffsetMode } from 'ontime-types';
|
import { TimerPhase, Playback, OffsetMode, type TimeOfDay } from 'ontime-types';
|
||||||
import { deepmerge } from 'ontime-utils';
|
import { deepmerge } from 'ontime-utils';
|
||||||
import type { RuntimeState } from '../runtimeState.js';
|
import type { RuntimeState } from '../runtimeState.js';
|
||||||
|
|
||||||
const baseState: RuntimeState = {
|
const baseState: RuntimeState = {
|
||||||
clock: 0,
|
clock: 0 as TimeOfDay,
|
||||||
eventNow: null,
|
eventNow: null,
|
||||||
eventNext: null,
|
eventNext: null,
|
||||||
eventFlag: null,
|
eventFlag: null,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types';
|
||||||
|
import { deepmerge } from 'ontime-utils';
|
||||||
|
|
||||||
|
const baseStore: RuntimeStore = {
|
||||||
|
...runtimeStorePlaceholder
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeRuntimeStoreData(patch?: Partial<RuntimeStore>): RuntimeStore {
|
||||||
|
return deepmerge(baseStore, patch) as RuntimeStore;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
import { Instant, PlayableEvent, Playback, SupportedEntry, TimerPhase } from 'ontime-types';
|
||||||
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||||
@@ -256,8 +257,8 @@ describe('mutation on runtimeState', () => {
|
|||||||
order: ['event1'],
|
order: ['event1'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const startEpoch = new Date('2024-01-01T00:00:00Z').getTime();
|
const startEpoch = new Date('jan 1 00:00').getTime() as Instant;
|
||||||
vi.setSystemTime(new Date('2024-01-03T00:00:00Z'));
|
vi.setSystemTime('jan 3 23:59:59');
|
||||||
|
|
||||||
await initRundown(mockRundown, {});
|
await initRundown(mockRundown, {});
|
||||||
vi.runAllTimers();
|
vi.runAllTimers();
|
||||||
@@ -280,7 +281,8 @@ describe('mutation on runtimeState', () => {
|
|||||||
expect(newState.rundown.actualStart).toBe(60 * 1000);
|
expect(newState.rundown.actualStart).toBe(60 * 1000);
|
||||||
expect(newState.rundown.currentDay).toBe(2);
|
expect(newState.rundown.currentDay).toBe(2);
|
||||||
|
|
||||||
vi.setSystemTime(new Date('2024-01-04T00:00:00Z'));
|
// crossing midnight increments currentDay
|
||||||
|
vi.setSystemTime('jan 4 00:00:01');
|
||||||
update();
|
update();
|
||||||
expect(getState().rundown.currentDay).toBe(3);
|
expect(getState().rundown.currentDay).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -297,6 +299,437 @@ describe('roll mode', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('overnight event sets correct currentDay', () => {
|
||||||
|
test('roll into overnight event after midnight sets currentDay correctly', async () => {
|
||||||
|
// Event spans 23:30 to 00:30 (overnight)
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
||||||
|
timeEnd: 30 * 60 * 1000, // 00:30
|
||||||
|
duration: 60 * 60 * 1000, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Clock is at 00:10 (morning part of overnight event)
|
||||||
|
vi.setSystemTime('jan 1 00:10');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
const result = roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
// currentDay should be 1 because we're in the "next day" part of the overnight event
|
||||||
|
expect(state.rundown.currentDay).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('roll into overnight event before midnight sets currentDay correctly', async () => {
|
||||||
|
// Event spans 23:30 to 00:30 (overnight)
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
||||||
|
timeEnd: 30 * 60 * 1000, // 00:30
|
||||||
|
duration: 60 * 60 * 1000, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Clock is at 23:40 (evening part of overnight event)
|
||||||
|
vi.setSystemTime('jan 1 23:40');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
const result = roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
// currentDay should be 0 because we're still on the same day as the event start
|
||||||
|
expect(state.rundown.currentDay).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('currentDay increments when crossing midnight during roll', async () => {
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 23 * 60 * 60 * 1000 + 30 * 60 * 1000, // 23:30
|
||||||
|
timeEnd: 30 * 60 * 1000, // 00:30
|
||||||
|
duration: 60 * 60 * 1000, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Start at 23:40
|
||||||
|
vi.setSystemTime('jan 1 23:40');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(getState().rundown.currentDay).toBe(0);
|
||||||
|
|
||||||
|
// Cross midnight
|
||||||
|
vi.setSystemTime('jan 2 00:10');
|
||||||
|
update();
|
||||||
|
|
||||||
|
expect(getState().rundown.currentDay).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending roll crossing midnight keeps currentDay null', async () => {
|
||||||
|
// Event starts at 01:00 (future event, will be pending)
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
||||||
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
||||||
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Clock is at 23:50 (before midnight, event is pending for tomorrow 01:00)
|
||||||
|
vi.setSystemTime('jan 1 23:50');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
const result = roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(false);
|
||||||
|
|
||||||
|
const stateBeforeMidnight = getState();
|
||||||
|
expect(stateBeforeMidnight.timer.secondaryTimer).not.toBeNull();
|
||||||
|
// currentDay is null when pending (rundown hasn't started)
|
||||||
|
expect(stateBeforeMidnight.rundown.currentDay).toBeNull();
|
||||||
|
|
||||||
|
// Cross midnight while still pending
|
||||||
|
vi.setSystemTime('jan 2 00:10');
|
||||||
|
update();
|
||||||
|
|
||||||
|
const stateAfterMidnight = getState();
|
||||||
|
// currentDay stays null while pending (rundown hasn't started yet)
|
||||||
|
expect(stateAfterMidnight.rundown.currentDay).toBeNull();
|
||||||
|
// should still be pending (event starts at 01:00)
|
||||||
|
expect(stateAfterMidnight.timer.secondaryTimer).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('waiting between events crossing midnight increments currentDay', async () => {
|
||||||
|
// Event 1: 23:00-23:30, Event 2: 00:30-01:00 (gap over midnight)
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||||
|
timeEnd: 23 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 23:30
|
||||||
|
duration: 30 * MILLIS_PER_MINUTE,
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '2',
|
||||||
|
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30
|
||||||
|
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00
|
||||||
|
duration: 30 * MILLIS_PER_MINUTE,
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1', '2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
|
||||||
|
// Start event 1 at 23:05
|
||||||
|
vi.setSystemTime('jan 1 23:05');
|
||||||
|
const startEpoch = Date.now() as Instant;
|
||||||
|
let result = roll(rundown, metadata);
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
expect(getState().rundown.currentDay).toBe(0);
|
||||||
|
|
||||||
|
// Simulate event 1 finishing and loading event 2 (what runtime service does)
|
||||||
|
// Load event 2 and call roll to put it in pending state
|
||||||
|
vi.setSystemTime('jan 1 23:35');
|
||||||
|
load(rundown.entries['2'] as PlayableEvent, rundown, metadata, {
|
||||||
|
firstStart: getState().rundown.actualStart,
|
||||||
|
startEpoch,
|
||||||
|
});
|
||||||
|
result = roll(rundown, metadata);
|
||||||
|
expect(result.eventId).toBe('2');
|
||||||
|
expect(result.didStart).toBe(false); // pending for 00:30
|
||||||
|
expect(getState().timer.secondaryTimer).not.toBeNull();
|
||||||
|
expect(getState().rundown.currentDay).toBe(0);
|
||||||
|
|
||||||
|
// Cross midnight while waiting for event 2
|
||||||
|
vi.setSystemTime('jan 2 00:10');
|
||||||
|
update();
|
||||||
|
|
||||||
|
// currentDay should increment even while waiting between events
|
||||||
|
expect(getState().rundown.currentDay).toBe(1);
|
||||||
|
expect(getState().timer.secondaryTimer).not.toBeNull(); // still waiting
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending roll sets currentDay to 0 when event starts (rundown starts fresh)', async () => {
|
||||||
|
// Event starts at 01:00
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
||||||
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
||||||
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Start pending at 23:50
|
||||||
|
vi.setSystemTime('jan 1 23:50');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
roll(rundown, metadata);
|
||||||
|
|
||||||
|
// Cross midnight
|
||||||
|
vi.setSystemTime('jan 2 00:10');
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Now the event starts at 01:05 - call roll again to simulate runtime service
|
||||||
|
vi.setSystemTime('jan 2 01:05');
|
||||||
|
const result = roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
// currentDay is 0 because the rundown just started (this is day 0)
|
||||||
|
// the pending time before midnight doesn't count as rundown time
|
||||||
|
expect(state.rundown.currentDay).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pending roll crossing midnight updates secondaryTimer correctly', async () => {
|
||||||
|
// Event starts at 01:00
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 1 * MILLIS_PER_HOUR, // 01:00
|
||||||
|
timeEnd: 2 * MILLIS_PER_HOUR, // 02:00
|
||||||
|
duration: 1 * MILLIS_PER_HOUR, // 1 hour
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
// Clock is at 23:50
|
||||||
|
vi.setSystemTime('jan 1 23:50');
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
roll(rundown, metadata);
|
||||||
|
|
||||||
|
const stateBeforeMidnight = getState();
|
||||||
|
// secondaryTimer should be time until 01:00 (1h 10min = 70 min)
|
||||||
|
expect(stateBeforeMidnight.timer.secondaryTimer).toBe(70 * MILLIS_PER_MINUTE);
|
||||||
|
|
||||||
|
// Cross midnight to 00:10
|
||||||
|
vi.setSystemTime('jan 2 00:10');
|
||||||
|
update();
|
||||||
|
|
||||||
|
const stateAfterMidnight = getState();
|
||||||
|
// secondaryTimer should now be 50 minutes until 01:00
|
||||||
|
expect(stateAfterMidnight.timer.secondaryTimer).toBe(50 * MILLIS_PER_MINUTE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rolling into overnight event after midnight has correct offset and expected times', async () => {
|
||||||
|
// Simulates a rundown with:
|
||||||
|
// - A group starting at 13:00, containing an overnight event
|
||||||
|
// - Overnight event: 23:50 to 01:50 (2 hours)
|
||||||
|
// Roll into the event at 00:21 (31 minutes into the event)
|
||||||
|
const groupId = 'group-1';
|
||||||
|
const eventId = 'event-1';
|
||||||
|
const eventStart = 23 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 23:50
|
||||||
|
const eventEnd = 1 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 01:50
|
||||||
|
const eventDuration = 2 * MILLIS_PER_HOUR; // 2 hours
|
||||||
|
const groupStart = 13 * MILLIS_PER_HOUR; // 13:00
|
||||||
|
const groupDuration = 12 * MILLIS_PER_HOUR + 50 * MILLIS_PER_MINUTE; // 12h 50m (ends at 01:50)
|
||||||
|
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
[groupId]: {
|
||||||
|
id: groupId,
|
||||||
|
type: SupportedEntry.Group,
|
||||||
|
title: 'Test Group',
|
||||||
|
timeStart: groupStart,
|
||||||
|
timeEnd: eventEnd,
|
||||||
|
duration: groupDuration,
|
||||||
|
entries: [eventId],
|
||||||
|
colour: '',
|
||||||
|
note: '',
|
||||||
|
custom: {},
|
||||||
|
revision: 0,
|
||||||
|
isFirstLinked: false,
|
||||||
|
targetDuration: null,
|
||||||
|
},
|
||||||
|
[eventId]: {
|
||||||
|
...mockEvent,
|
||||||
|
id: eventId,
|
||||||
|
timeStart: eventStart,
|
||||||
|
timeEnd: eventEnd,
|
||||||
|
duration: eventDuration,
|
||||||
|
dayOffset: 0,
|
||||||
|
parent: groupId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: [groupId],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
|
||||||
|
// Roll into the event AFTER midnight at 00:21 (31 minutes into the 2-hour event)
|
||||||
|
const rollTime = 21 * MILLIS_PER_MINUTE; // 00:21
|
||||||
|
vi.setSystemTime('jan 2 00:21');
|
||||||
|
const result = roll(rundown, metadata);
|
||||||
|
|
||||||
|
expect(result.eventId).toBe(eventId);
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
|
||||||
|
// Call update to recalculate all state (like runtime service does)
|
||||||
|
update();
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
// currentDay should be 1 (we're on the next day after midnight)
|
||||||
|
expect(state.rundown.currentDay).toBe(1);
|
||||||
|
|
||||||
|
// offset.absolute should be 0 (event started on time, backdated to planned start)
|
||||||
|
expect(state.offset.absolute).toBe(0);
|
||||||
|
|
||||||
|
// Timer should show correct remaining time
|
||||||
|
// Event started at 23:50, duration is 2 hours, clock is 00:21
|
||||||
|
// Time elapsed = 31 minutes, time remaining = 2h - 31m = 1h 29m = 89 minutes
|
||||||
|
const expectedRemaining = eventDuration - (rollTime + (24 * MILLIS_PER_HOUR - eventStart));
|
||||||
|
expect(state.timer.current).toBe(expectedRemaining);
|
||||||
|
|
||||||
|
// expectedFinish should be 01:50 (event end time)
|
||||||
|
expect(state.timer.expectedFinish).toBe(eventEnd);
|
||||||
|
|
||||||
|
// expectedRundownEnd should be 01:50 (same as event end, only one event)
|
||||||
|
expect(state.offset.expectedRundownEnd).toBe(eventEnd);
|
||||||
|
|
||||||
|
// expectedGroupEnd should be 01:50 (group ends when the event ends)
|
||||||
|
expect(state.offset.expectedGroupEnd).toBe(eventEnd);
|
||||||
|
|
||||||
|
// Verify timer started at correct backdated time
|
||||||
|
expect(state.timer.startedAt).toBe(eventStart);
|
||||||
|
|
||||||
|
// Verify actualStart is backdated to planned start
|
||||||
|
expect(state.rundown.actualStart).toBe(eventStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rundown loops back after finishing and resets currentDay when first event starts again', async () => {
|
||||||
|
// Rundown: 09:00-23:00, then overnight event 23:00-01:00
|
||||||
|
const mockRundown = makeRundown({
|
||||||
|
entries: {
|
||||||
|
1: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '1',
|
||||||
|
timeStart: 9 * MILLIS_PER_HOUR, // 09:00
|
||||||
|
timeEnd: 23 * MILLIS_PER_HOUR, // 23:00
|
||||||
|
duration: 14 * MILLIS_PER_HOUR, // 14 hours
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
...mockEvent,
|
||||||
|
id: '2',
|
||||||
|
timeStart: 23 * MILLIS_PER_HOUR, // 23:00
|
||||||
|
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00 (next day)
|
||||||
|
duration: 2 * MILLIS_PER_HOUR, // 2 hours
|
||||||
|
dayOffset: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
order: ['1', '2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
await initRundown(mockRundown, {});
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
const { rundown, metadata } = rundownCache.get();
|
||||||
|
|
||||||
|
// Day 1: Start at 09:05 - first event is playing
|
||||||
|
vi.setSystemTime('jan 1 09:05');
|
||||||
|
let result = roll(rundown, metadata);
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
expect(getState().rundown.currentDay).toBe(0);
|
||||||
|
|
||||||
|
// Day 1: Move to 23:05 - second event (overnight) is playing
|
||||||
|
vi.setSystemTime('jan 1 23:05');
|
||||||
|
result = roll(rundown, metadata);
|
||||||
|
expect(result.eventId).toBe('2');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
|
||||||
|
// Cross midnight
|
||||||
|
vi.setSystemTime('jan 2 00:30');
|
||||||
|
update();
|
||||||
|
expect(getState().rundown.currentDay).toBe(1);
|
||||||
|
|
||||||
|
// Day 2: 01:05 - rundown finishes, loops back to first event (pending for 09:00)
|
||||||
|
vi.setSystemTime('jan 2 01:05');
|
||||||
|
result = roll(rundown, metadata);
|
||||||
|
// First event is pending (09:00 is in the future)
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(false);
|
||||||
|
// currentDay is null while pending (rundown hasn't "restarted" yet)
|
||||||
|
expect(getState().rundown.currentDay).toBeNull();
|
||||||
|
|
||||||
|
// Day 2: 09:05 - first event starts again (fresh rundown cycle)
|
||||||
|
vi.setSystemTime('jan 2 09:05');
|
||||||
|
result = roll(rundown, metadata);
|
||||||
|
expect(result.eventId).toBe('1');
|
||||||
|
expect(result.didStart).toBe(true);
|
||||||
|
// currentDay resets to 0 for the new cycle
|
||||||
|
expect(getState().rundown.currentDay).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('normal roll', async () => {
|
describe('normal roll', async () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
Day,
|
||||||
|
Duration,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
|
Instant,
|
||||||
MaybeNumber,
|
MaybeNumber,
|
||||||
MaybeString,
|
MaybeString,
|
||||||
OffsetMode,
|
OffsetMode,
|
||||||
@@ -10,9 +13,11 @@ import {
|
|||||||
Rundown,
|
Rundown,
|
||||||
Offset,
|
Offset,
|
||||||
runtimeStorePlaceholder,
|
runtimeStorePlaceholder,
|
||||||
|
TimeOfDay,
|
||||||
TimerPhase,
|
TimerPhase,
|
||||||
TimerState,
|
TimerState,
|
||||||
RundownState,
|
RundownState,
|
||||||
|
Maybe,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import {
|
import {
|
||||||
calculateDuration,
|
calculateDuration,
|
||||||
@@ -23,7 +28,6 @@ import {
|
|||||||
isPlaybackActive,
|
isPlaybackActive,
|
||||||
} from 'ontime-utils';
|
} from 'ontime-utils';
|
||||||
|
|
||||||
import { getTimeObject, timeNow } from '../utils/time.js';
|
|
||||||
import type { RestorePoint } from '../services/restore-service/restore.type.js';
|
import type { RestorePoint } from '../services/restore-service/restore.type.js';
|
||||||
import {
|
import {
|
||||||
findDayOffset,
|
findDayOffset,
|
||||||
@@ -31,16 +35,22 @@ import {
|
|||||||
getExpectedFinish,
|
getExpectedFinish,
|
||||||
getRuntimeOffset,
|
getRuntimeOffset,
|
||||||
getTimerPhase,
|
getTimerPhase,
|
||||||
|
hasCrossedMidnight,
|
||||||
} from '../services/timerUtils.js';
|
} from '../services/timerUtils.js';
|
||||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||||
import { timerConfig } from '../setup/config.js';
|
import { timerConfig } from '../setup/config.js';
|
||||||
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
|
import { RundownMetadata } from '../api-data/rundown/rundown.types.js';
|
||||||
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
|
import { getPlayableIndexFromTimedIndex } from '../api-data/rundown/rundown.utils.js';
|
||||||
|
import * as timeCore from '../lib/time-core/timeCore.js';
|
||||||
|
|
||||||
type ExpectedMetadata = { event: OntimeEvent; accumulatedGap: number; isLinkedToLoaded: boolean } | null;
|
type ExpectedMetadata = {
|
||||||
|
event: OntimeEvent;
|
||||||
|
accumulatedGap: number;
|
||||||
|
isLinkedToLoaded: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export type RuntimeState = {
|
export type RuntimeState = {
|
||||||
clock: number; // realtime clock
|
clock: TimeOfDay;
|
||||||
groupNow: OntimeGroup | null;
|
groupNow: OntimeGroup | null;
|
||||||
eventNow: PlayableEvent | null;
|
eventNow: PlayableEvent | null;
|
||||||
eventNext: PlayableEvent | null;
|
eventNext: PlayableEvent | null;
|
||||||
@@ -50,9 +60,9 @@ export type RuntimeState = {
|
|||||||
rundown: RundownState;
|
rundown: RundownState;
|
||||||
// private properties of the timer calculations
|
// private properties of the timer calculations
|
||||||
_timer: {
|
_timer: {
|
||||||
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||||
pausedAt: MaybeNumber;
|
pausedAt: Maybe<TimeOfDay>;
|
||||||
secondaryTarget: MaybeNumber;
|
secondaryTarget: Maybe<TimeOfDay>;
|
||||||
hasFinished: boolean;
|
hasFinished: boolean;
|
||||||
};
|
};
|
||||||
_rundown: {
|
_rundown: {
|
||||||
@@ -61,12 +71,12 @@ export type RuntimeState = {
|
|||||||
_group: ExpectedMetadata;
|
_group: ExpectedMetadata;
|
||||||
_flag: ExpectedMetadata;
|
_flag: ExpectedMetadata;
|
||||||
_end: ExpectedMetadata;
|
_end: ExpectedMetadata;
|
||||||
_startEpoch: MaybeNumber;
|
_startEpoch: Maybe<Instant>;
|
||||||
_startDayOffset: MaybeNumber;
|
_startDayOffset: Maybe<Day>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeState: RuntimeState = {
|
const runtimeState: RuntimeState = {
|
||||||
clock: timeNow(),
|
clock: timeCore.timeOfDayNow(),
|
||||||
groupNow: null,
|
groupNow: null,
|
||||||
eventNow: null,
|
eventNow: null,
|
||||||
eventNext: null,
|
eventNext: null,
|
||||||
@@ -122,7 +132,7 @@ export function clearEventData() {
|
|||||||
runtimeState.rundown.selectedEventIndex = null;
|
runtimeState.rundown.selectedEventIndex = null;
|
||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
runtimeState.clock = timeNow();
|
runtimeState.clock = timeCore.timeOfDayNow();
|
||||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
@@ -154,7 +164,7 @@ export function clearState() {
|
|||||||
runtimeState._end = null;
|
runtimeState._end = null;
|
||||||
|
|
||||||
runtimeState.timer.playback = Playback.Stop;
|
runtimeState.timer.playback = Playback.Stop;
|
||||||
runtimeState.clock = timeNow();
|
runtimeState.clock = timeCore.timeOfDayNow();
|
||||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||||
|
|
||||||
// when clearing, we maintain the total delay from the rundown
|
// when clearing, we maintain the total delay from the rundown
|
||||||
@@ -354,10 +364,13 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
|
|||||||
if (runtimeState._timer.secondaryTarget !== null) {
|
if (runtimeState._timer.secondaryTarget !== null) {
|
||||||
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
|
if (runtimeState.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
|
||||||
// if the event is now, we queue a start
|
// if the event is now, we queue a start
|
||||||
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart;
|
runtimeState._timer.secondaryTarget = runtimeState.eventNow.timeStart as TimeOfDay;
|
||||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||||
} else {
|
} else {
|
||||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||||
|
runtimeState.eventNow.timeStart,
|
||||||
|
offsetClock,
|
||||||
|
) as TimeOfDay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,7 +416,9 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [epoch, now] = getTimeObject();
|
const epoch = timeCore.now();
|
||||||
|
const now = timeCore.toTimeOfDay(epoch);
|
||||||
|
|
||||||
state.clock = now;
|
state.clock = now;
|
||||||
state.timer.secondaryTimer = null;
|
state.timer.secondaryTimer = null;
|
||||||
|
|
||||||
@@ -423,7 +438,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
|||||||
state.timer.elapsed = 0;
|
state.timer.elapsed = 0;
|
||||||
|
|
||||||
if (state.rundown.actualStart === null) {
|
if (state.rundown.actualStart === null) {
|
||||||
state._startDayOffset = findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset;
|
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||||
state.rundown.currentDay = state._startDayOffset;
|
state.rundown.currentDay = state._startDayOffset;
|
||||||
state._startEpoch = epoch;
|
state._startEpoch = epoch;
|
||||||
state.rundown.actualStart = state.clock;
|
state.rundown.actualStart = state.clock;
|
||||||
@@ -459,7 +474,7 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.timer.playback = Playback.Pause;
|
state.timer.playback = Playback.Pause;
|
||||||
state.clock = timeNow();
|
state.clock = timeCore.timeOfDayNow();
|
||||||
state._timer.pausedAt = state.clock;
|
state._timer.pausedAt = state.clock;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -494,7 +509,7 @@ export function addTime(amount: number) {
|
|||||||
|
|
||||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||||
// set finished time so side effects are triggered
|
// set finished time so side effects are triggered
|
||||||
runtimeState._timer.forceFinish = timeNow();
|
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||||
} else {
|
} else {
|
||||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||||
if (willGoPositive) {
|
if (willGoPositive) {
|
||||||
@@ -524,7 +539,8 @@ export type UpdateResult = {
|
|||||||
export function update(): UpdateResult {
|
export function update(): UpdateResult {
|
||||||
// 0. there are some things we always do
|
// 0. there are some things we always do
|
||||||
const previousClock = runtimeState.clock;
|
const previousClock = runtimeState.clock;
|
||||||
const [epoch, now] = getTimeObject();
|
const epoch = timeCore.now();
|
||||||
|
const now = timeCore.toTimeOfDay(epoch);
|
||||||
runtimeState.clock = now; // we update the clock on every update call
|
runtimeState.clock = now; // we update the clock on every update call
|
||||||
|
|
||||||
// 1. is playback idle?
|
// 1. is playback idle?
|
||||||
@@ -532,16 +548,16 @@ export function update(): UpdateResult {
|
|||||||
return updateIfIdle();
|
return updateIfIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we are playing and playback changes. we tick the current runtime day
|
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||||
if (runtimeState._startDayOffset !== null && runtimeState._startEpoch) {
|
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||||
runtimeState.rundown.currentDay =
|
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||||
runtimeState._startDayOffset + Math.floor((epoch - runtimeState._startEpoch) / dayInMs);
|
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. are we waiting to roll?
|
// 2. are we waiting to roll?
|
||||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||||
const hasCrossedMidnight = previousClock > runtimeState.clock;
|
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||||
return updateIfWaitingToRoll(hasCrossedMidnight);
|
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. at this point we know that we are playing an event
|
// 3. at this point we know that we are playing an event
|
||||||
@@ -600,11 +616,17 @@ export function update(): UpdateResult {
|
|||||||
if (hasCrossedMidnight) {
|
if (hasCrossedMidnight) {
|
||||||
// if we crossed midnight, we need to update the target
|
// if we crossed midnight, we need to update the target
|
||||||
// this is the same logic from the roll function
|
// this is the same logic from the roll function
|
||||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||||
|
runtimeState.eventNow.timeStart,
|
||||||
|
offsetClock,
|
||||||
|
) as TimeOfDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
|
||||||
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
|
return {
|
||||||
|
hasTimerFinished: false,
|
||||||
|
hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +642,8 @@ export function roll(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// we will need to do some calculations, update the time first
|
// we will need to do some calculations, update the time first
|
||||||
const [epoch, now] = getTimeObject();
|
const epoch = timeCore.now();
|
||||||
|
const now = timeCore.toTimeOfDay(epoch);
|
||||||
runtimeState.clock = now;
|
runtimeState.clock = now;
|
||||||
|
|
||||||
// 2. if there is an event armed, we use it
|
// 2. if there is an event armed, we use it
|
||||||
@@ -668,13 +691,27 @@ export function roll(
|
|||||||
runtimeState.rundown.actualGroupStart = plannedStart;
|
runtimeState.rundown.actualGroupStart = plannedStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* we need to backdate the actual start and start metadata
|
||||||
|
* to prevent adding unintended offset
|
||||||
|
*/
|
||||||
if (runtimeState.rundown.actualStart === null) {
|
if (runtimeState.rundown.actualStart === null) {
|
||||||
runtimeState.rundown.actualStart = plannedStart;
|
runtimeState.rundown.actualStart = plannedStart;
|
||||||
runtimeState._startDayOffset = 0;
|
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||||
runtimeState._startEpoch = epoch;
|
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||||
|
runtimeState.eventNow.dayOffset) as Day;
|
||||||
|
// backdate _startEpoch to when the event conceptually started
|
||||||
|
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||||
|
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||||
|
// calculate currentDay from the backdated epoch
|
||||||
|
runtimeState.rundown.currentDay =
|
||||||
|
runtimeState._startDayOffset + timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
runtimeState._timer.secondaryTarget = normaliseRollStart(
|
||||||
|
runtimeState.eventNow.timeStart,
|
||||||
|
offsetClock,
|
||||||
|
) as TimeOfDay;
|
||||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||||
runtimeState.timer.phase = TimerPhase.Pending;
|
runtimeState.timer.phase = TimerPhase.Pending;
|
||||||
}
|
}
|
||||||
@@ -720,12 +757,19 @@ export function roll(
|
|||||||
// there is nothing now, but something coming up
|
// there is nothing now, but something coming up
|
||||||
runtimeState.timer.phase = TimerPhase.Pending;
|
runtimeState.timer.phase = TimerPhase.Pending;
|
||||||
// we need to normalise start time in case it is the day after
|
// we need to normalise start time in case it is the day after
|
||||||
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
|
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock) as TimeOfDay;
|
||||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
|
||||||
|
|
||||||
// preload timer properties
|
// preload timer properties
|
||||||
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
|
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
|
||||||
runtimeState.timer.current = runtimeState.timer.duration;
|
runtimeState.timer.current = runtimeState.timer.duration;
|
||||||
|
|
||||||
|
// reset rundown state - the rundown will start fresh when the event begins
|
||||||
|
runtimeState.rundown.actualStart = null;
|
||||||
|
runtimeState.rundown.currentDay = null;
|
||||||
|
runtimeState._startDayOffset = null;
|
||||||
|
runtimeState._startEpoch = null;
|
||||||
|
|
||||||
return { eventId: runtimeState.eventNow.id, didStart: false };
|
return { eventId: runtimeState.eventNow.id, didStart: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,8 +801,15 @@ export function roll(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// update metadata
|
// update metadata
|
||||||
runtimeState._startDayOffset = 0;
|
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||||
runtimeState._startEpoch = epoch;
|
runtimeState._startDayOffset = (findDayOffset(runtimeState.eventNow.timeStart, plannedStart) +
|
||||||
|
runtimeState.eventNow.dayOffset) as Day;
|
||||||
|
// backdate _startEpoch to when the event conceptually started
|
||||||
|
const timeElapsed = timeCore.elapsedTime(runtimeState.clock, plannedStart as TimeOfDay);
|
||||||
|
runtimeState._startEpoch = timeCore.addDuration(epoch, -timeElapsed as Duration);
|
||||||
|
// calculate currentDay from the backdated epoch
|
||||||
|
runtimeState.rundown.currentDay = (runtimeState._startDayOffset +
|
||||||
|
timeCore.daysSinceStart(runtimeState._startEpoch, epoch)) as Day;
|
||||||
|
|
||||||
return { eventId: runtimeState.eventNow.id, didStart: true };
|
return { eventId: runtimeState.eventNow.id, didStart: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import { LogOrigin } from 'ontime-types';
|
|
||||||
|
|
||||||
import type { Server } from 'http';
|
|
||||||
import { networkInterfaces } from 'os';
|
import { networkInterfaces } from 'os';
|
||||||
import type { AddressInfo } from 'net';
|
|
||||||
|
|
||||||
import { isDocker, isOntimeCloud, isProduction } from '../setup/environment.js';
|
|
||||||
import { logger } from '../classes/Logger.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Gets information on IPV4 non-internal interfaces
|
* @description Gets information on IPV4 non-internal interfaces
|
||||||
@@ -33,84 +26,3 @@ export function getNetworkInterfaces(): { name: string; address: string }[] {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to an random port assigned by the OS
|
|
||||||
* @param {http.Server} server http server object
|
|
||||||
* @param {number} desiredPort the desired port
|
|
||||||
* @returns {number} the resulting port number
|
|
||||||
* @throws any other server errors will result in a throw
|
|
||||||
*/
|
|
||||||
export function serverTryDesiredPort(server: Server, desiredPort: number): Promise<number> {
|
|
||||||
if (isOntimeCloud) {
|
|
||||||
return forceCloudPort(server);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.once('error', (error) => {
|
|
||||||
// we should only move ports if we are in a desktop environment
|
|
||||||
if (isDocker || !isProduction) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isPortInUseError(error)) {
|
|
||||||
reject(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we get an address in use error, we will try to open the server in an ephemeral port
|
|
||||||
// port 0 will assign an ephemeral port
|
|
||||||
server.listen(0, '0.0.0.0', () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!isAddressInfo(address)) {
|
|
||||||
reject(new Error('Unknown port type, unable to proceed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.error(
|
|
||||||
LogOrigin.Server,
|
|
||||||
`Failed open the desired port: ${desiredPort} \nMoved to an Ephemeral port: ${address.port}`,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
resolve(address.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(desiredPort, '0.0.0.0', () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!isAddressInfo(address)) {
|
|
||||||
reject(new Error('Unknown port type, unable to proceed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(address.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function forceCloudPort(server: Server): Promise<number> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
server.listen(4001, '0.0.0.0', () => {
|
|
||||||
const address = server.address();
|
|
||||||
if (!isAddressInfo(address)) {
|
|
||||||
reject(new Error('Unknown port type, unable to proceed'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(address.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guard verifies that the given address is a usable AddressInfo object
|
|
||||||
*/
|
|
||||||
function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo {
|
|
||||||
return typeof address === 'object' && address !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a given error is a port in use error
|
|
||||||
*/
|
|
||||||
function isPortInUseError(err: Error): boolean {
|
|
||||||
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -74,18 +74,3 @@ export function timeNow() {
|
|||||||
elapsed += now.getMilliseconds();
|
elapsed += now.getMilliseconds();
|
||||||
return elapsed;
|
return elapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current time from system
|
|
||||||
* @returns [number, number] - [epoch time, milliseconds since midnight]
|
|
||||||
*/
|
|
||||||
export function getTimeObject(): [number, number] {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// extract milliseconds since midnight
|
|
||||||
let elapsed = now.getHours() * 3600000;
|
|
||||||
elapsed += now.getMinutes() * 60000;
|
|
||||||
elapsed += now.getSeconds() * 1000;
|
|
||||||
elapsed += now.getMilliseconds();
|
|
||||||
return [now.getTime(), elapsed];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -460,7 +460,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "-",
|
"version": "-",
|
||||||
"serverPort": 4001,
|
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
"timeFormat": "24",
|
"timeFormat": "24",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"extends": "../../tsconfig.common.json",
|
"extends": "../../tsconfig.common.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
|
"composite": true,
|
||||||
"module": "node16",
|
"module": "node16",
|
||||||
"moduleResolution": "node16",
|
"moduleResolution": "node16",
|
||||||
"noImplicitReturns": false, //TODO: fix this
|
"noImplicitReturns": false, //TODO: fix this
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.test.ts",
|
"src/**/*.test.ts",
|
||||||
"src/**/*.spec.ts",
|
"src/**/*.spec.ts"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
|
globalSetup: './vitest.global-setup.ts',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function setup() {
|
||||||
|
process.env.TZ = 'Europe/Copenhagen';
|
||||||
|
}
|
||||||
@@ -132,14 +132,16 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
await page.close();
|
await page.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Sharing a link with readonly permissions', async ({ page }) => {
|
test('Sharing a link with readonly permissions', async ({ page }, testInfo) => {
|
||||||
|
const alias = `cuesheet-read-test-${testInfo.retry}-${Date.now()}`;
|
||||||
|
|
||||||
await page.goto('http://localhost:4001/cuesheet');
|
await page.goto('http://localhost:4001/cuesheet');
|
||||||
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share...' }).click();
|
await page.getByRole('button', { name: 'Share...' }).click();
|
||||||
|
|
||||||
// configure share for readonly
|
// configure share for readonly
|
||||||
await page.getByRole('textbox').fill('cuesheet-read-test');
|
await page.locator('input[name="alias"]').fill(alias);
|
||||||
await page.getByText('Custom write').click();
|
await page.getByText('Custom write').click();
|
||||||
await page.getByText('Custom read').click();
|
await page.getByText('Custom read').click();
|
||||||
await page.getByTestId('lockNav').click();
|
await page.getByTestId('lockNav').click();
|
||||||
@@ -151,9 +153,16 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
await page.getByTestId('write-duration').click();
|
await page.getByTestId('write-duration').click();
|
||||||
await page.getByTestId('write-note').click();
|
await page.getByTestId('write-note').click();
|
||||||
|
|
||||||
|
// disable any custom write permissions if they exist
|
||||||
|
const customWriteSwitches = page.locator('[data-testid^="write-custom-"]');
|
||||||
|
const customWriteSwitchCount = await customWriteSwitches.count();
|
||||||
|
for (let i = 0; i < customWriteSwitchCount; i++) {
|
||||||
|
await customWriteSwitches.nth(i).click();
|
||||||
|
}
|
||||||
|
|
||||||
// create and verify link
|
// create and verify link
|
||||||
await page.getByRole('button', { name: 'Create share link' }).click();
|
await page.getByRole('button', { name: 'Create share link' }).click();
|
||||||
await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-read-test');
|
await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`);
|
||||||
await expect(page.getByTestId('copy-link')).toContainText('n=1');
|
await expect(page.getByTestId('copy-link')).toContainText('n=1');
|
||||||
|
|
||||||
// verify the preset
|
// verify the preset
|
||||||
@@ -165,8 +174,9 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
|
await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
|
||||||
await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
|
await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
|
||||||
|
|
||||||
// check that we are locked and cannot edit
|
// mode toggle should not be rendered for readonly users
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await expect(page.getByRole('button', { name: 'Edit' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Run' })).toHaveCount(0);
|
||||||
|
|
||||||
// Verify that the title is visible but not editable
|
// Verify that the title is visible but not editable
|
||||||
await expect(page.getByTestId('cuesheet-event').getByText('title 1')).toBeVisible();
|
await expect(page.getByTestId('cuesheet-event').getByText('title 1')).toBeVisible();
|
||||||
@@ -176,14 +186,16 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible();
|
await expect(page.getByRole('cell', { name: 'Duration' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Sharing a link with scoped read-write permissions', async ({ page }) => {
|
test('Sharing a link with scoped read-write permissions', async ({ page }, testInfo) => {
|
||||||
|
const alias = `cuesheet-scope-test-${testInfo.retry}-${Date.now()}`;
|
||||||
|
|
||||||
await page.goto('http://localhost:4001/cuesheet');
|
await page.goto('http://localhost:4001/cuesheet');
|
||||||
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
await expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Share...' }).click();
|
await page.getByRole('button', { name: 'Share...' }).click();
|
||||||
|
|
||||||
// configure share for readonly
|
// configure share for readonly
|
||||||
await page.getByRole('textbox').fill('cuesheet-scope-test');
|
await page.locator('input[name="alias"]').fill(alias);
|
||||||
await page.getByText('Custom write').click();
|
await page.getByText('Custom write').click();
|
||||||
await page.getByText('Custom read').click();
|
await page.getByText('Custom read').click();
|
||||||
await page.getByTestId('lockNav').click();
|
await page.getByTestId('lockNav').click();
|
||||||
@@ -202,7 +214,7 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
|
|
||||||
// create and verify link
|
// create and verify link
|
||||||
await page.getByRole('button', { name: 'Create share link' }).click();
|
await page.getByRole('button', { name: 'Create share link' }).click();
|
||||||
await expect(page.getByTestId('copy-link')).toContainText('preset/cuesheet-scope-test');
|
await expect(page.getByTestId('copy-link')).toContainText(`preset/${alias}`);
|
||||||
await expect(page.getByTestId('copy-link')).toContainText('n=1');
|
await expect(page.getByTestId('copy-link')).toContainText('n=1');
|
||||||
|
|
||||||
// verify the preset
|
// verify the preset
|
||||||
@@ -219,6 +231,9 @@ test.describe('Sharing from cuesheet', () => {
|
|||||||
|
|
||||||
// Verify that the title is visible and editable
|
// Verify that the title is visible and editable
|
||||||
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
await expect(page.getByTestId('cuesheet-event').getByRole('cell', { name: 'title' })).toBeVisible();
|
||||||
|
const titleEditor = page.getByTestId('cuesheet-event').getByTestId('cuesheet-editor-title');
|
||||||
|
await titleEditor.click();
|
||||||
|
await expect(titleEditor).toBeEditable();
|
||||||
|
|
||||||
// other elements are not there
|
// other elements are not there
|
||||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||||
|
|||||||
Vendored
-1
@@ -477,7 +477,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "-",
|
"version": "-",
|
||||||
"serverPort": 4001,
|
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
"timeFormat": "24",
|
"timeFormat": "24",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "4.4.2",
|
"version": "4.5.0",
|
||||||
"name": "ontime-types",
|
"name": "ontime-types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export type NetworkInterface = {
|
|||||||
address: string;
|
address: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PortInfo = {
|
||||||
|
port: number;
|
||||||
|
pendingRestart: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export interface SessionStats {
|
export interface SessionStats {
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
connectedClients: number;
|
connectedClients: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TimerType } from '../TimerType.type.js';
|
|||||||
import type { TimeStrategy } from '../TimeStrategy.type.js';
|
import type { TimeStrategy } from '../TimeStrategy.type.js';
|
||||||
import type { Trigger } from './Automation.type.js';
|
import type { Trigger } from './Automation.type.js';
|
||||||
import type { EntryCustomFields } from './CustomFields.type.js';
|
import type { EntryCustomFields } from './CustomFields.type.js';
|
||||||
|
import type { Day } from './Temporal.js';
|
||||||
|
|
||||||
export type EntryId = string;
|
export type EntryId = string;
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
|||||||
// !==== RUNTIME METADATA ====! //
|
// !==== RUNTIME METADATA ====! //
|
||||||
revision: number;
|
revision: number;
|
||||||
delay: number; // calculated at runtime
|
delay: number; // calculated at runtime
|
||||||
dayOffset: number; // calculated at runtime
|
dayOffset: Day; // calculated at runtime
|
||||||
gap: number; // calculated at runtime
|
gap: number; // calculated at runtime
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { TimeFormat } from './TimeFormat.type.js';
|
|||||||
|
|
||||||
export type Settings = {
|
export type Settings = {
|
||||||
version: string;
|
version: string;
|
||||||
serverPort: number;
|
|
||||||
editorKey: null | string;
|
editorKey: null | string;
|
||||||
operatorKey: null | string;
|
operatorKey: null | string;
|
||||||
timeFormat: TimeFormat;
|
timeFormat: TimeFormat;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
declare const __brand: unique symbol;
|
||||||
|
type Brand<B> = { [__brand]: B };
|
||||||
|
type Branded<T, B> = T & Brand<B>;
|
||||||
|
|
||||||
|
/** A timestamp in milliseconds from epoch */
|
||||||
|
export type Instant = Branded<number, 'instant'>;
|
||||||
|
|
||||||
|
/** A timestamp of milliseconds since midnight today in the set timezone */
|
||||||
|
export type TimeOfDay = Branded<number, 'time-of-day'>;
|
||||||
|
|
||||||
|
/** A duration of milliseconds */
|
||||||
|
export type Duration = Branded<number, 'duration'>;
|
||||||
|
|
||||||
|
/** A day count integer */
|
||||||
|
export type Day = Branded<number, 'day'>;
|
||||||
@@ -28,9 +28,9 @@ type CuesheetUrlPreset = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string;
|
alias: string;
|
||||||
search: string;
|
search: string;
|
||||||
options: {
|
options?: {
|
||||||
read: string;
|
read?: string;
|
||||||
write: string;
|
write?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/cor
|
|||||||
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
||||||
export { TimerType } from './definitions/TimerType.type.js';
|
export { TimerType } from './definitions/TimerType.type.js';
|
||||||
|
|
||||||
|
// ---> Core
|
||||||
|
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
|
||||||
|
|
||||||
// ---> Report
|
// ---> Report
|
||||||
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ export type {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
ProjectLogoResponse,
|
ProjectLogoResponse,
|
||||||
|
PortInfo,
|
||||||
} from './api/ontime-controller/BackendResponse.type.js';
|
} from './api/ontime-controller/BackendResponse.type.js';
|
||||||
export type {
|
export type {
|
||||||
EventPostPayload,
|
EventPostPayload,
|
||||||
@@ -125,7 +129,7 @@ export {
|
|||||||
isOntimeAction,
|
isOntimeAction,
|
||||||
isTimerLifeCycle,
|
isTimerLifeCycle,
|
||||||
} from './utils/guards.js';
|
} from './utils/guards.js';
|
||||||
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
export type { Maybe, MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||||
|
|
||||||
// Colour
|
// Colour
|
||||||
export type { RGBColour } from './definitions/Colour.type.js';
|
export type { RGBColour } from './definitions/Colour.type.js';
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export type MaybeNumber = number | null;
|
export type MaybeNumber = number | null;
|
||||||
export type MaybeString = string | null;
|
export type MaybeString = string | null;
|
||||||
|
|
||||||
|
export type Maybe<T> = T | null;
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ describe('checkIsNow()', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should return true accounting for events that roll over midnight', () => {
|
test('should return true accounting for events that roll over midnight', () => {
|
||||||
expect(checkIsNow(22 * MILLIS_PER_HOUR, 8 * MILLIS_PER_HOUR, 23 * MILLIS_PER_HOUR)).toBe(true);
|
const timeStart = 22 * MILLIS_PER_HOUR;
|
||||||
|
const timeEnd = 8 * MILLIS_PER_HOUR;
|
||||||
|
const now = 23 * MILLIS_PER_HOUR;
|
||||||
|
|
||||||
|
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true accounting for events that roll over midnight (2)', () => {
|
||||||
|
const timeStart = 22 * MILLIS_PER_HOUR;
|
||||||
|
const timeEnd = 8 * MILLIS_PER_HOUR;
|
||||||
|
const now = 1 * MILLIS_PER_HOUR;
|
||||||
|
|
||||||
|
expect(checkIsNow(timeStart, timeEnd, now)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { dayInMs } from './conversionUtils.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function checks whether a given event should be playing now
|
* Utility function checks whether a given event should be playing now
|
||||||
*/
|
*/
|
||||||
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
|
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
|
||||||
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
|
if (timeEnd < timeStart) {
|
||||||
return timeStart <= clock && clock <= normalisedEnd;
|
// overnight event: clock is either after start OR before end
|
||||||
|
return clock >= timeStart || clock <= timeEnd;
|
||||||
|
}
|
||||||
|
return timeStart <= clock && clock <= timeEnd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
import type { Day, OntimeDelay, OntimeEvent, OntimeGroup, OntimeMilestone } from 'ontime-types';
|
||||||
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
import { EndAction, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
|
||||||
|
|
||||||
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||||
@@ -24,7 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
|||||||
parent: null,
|
parent: null,
|
||||||
revision: 0, // calculated at runtime
|
revision: 0, // calculated at runtime
|
||||||
delay: 0, // calculated at runtime
|
delay: 0, // calculated at runtime
|
||||||
dayOffset: 0, // calculated at runtime
|
dayOffset: 0 as Day, // calculated at runtime
|
||||||
gap: 0, // calculated at runtime
|
gap: 0, // calculated at runtime
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user