mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
23 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 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosResponse } from 'axios';
|
||||
import { Settings } from 'ontime-types';
|
||||
import { PortInfo, Settings } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
@@ -27,3 +27,18 @@ export async function postSettings(data: Settings): Promise<AxiosResponse<Settin
|
||||
export async function postShowWelcomeDialog(show: boolean) {
|
||||
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%;
|
||||
width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
|
||||
@@ -202,19 +202,19 @@ export const useGroupTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
mode: state.offset.mode,
|
||||
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,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
export const useFlagTimerOverView = createSelector((state: RuntimeStore) => ({
|
||||
clock: state.clock,
|
||||
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,
|
||||
plannedStart: state.rundown.plannedStart ?? 0,
|
||||
currentDay: state.eventNow?.dayOffset ?? 0,
|
||||
currentDay: state.rundown.currentDay ?? 0,
|
||||
playback: state.timer.playback,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Settings } from 'ontime-types';
|
||||
|
||||
export const ontimePlaceholderSettings: Settings = {
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -82,6 +82,57 @@ describe('getRouteFromPreset()', () => {
|
||||
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()', () => {
|
||||
@@ -115,6 +166,14 @@ describe('arePathsEquivalent()', () => {
|
||||
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', () => {
|
||||
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();
|
||||
|
||||
@@ -39,11 +39,13 @@ export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): str
|
||||
// NOTE: verify that this resolves correctly in cloud
|
||||
const currentPath = `${location.pathname}${location.search}`.substring(1);
|
||||
const currentURL = getCurrentPath(location);
|
||||
const token = new URLSearchParams(location.search).get('token');
|
||||
const isLocked = location.search.includes('n=1');
|
||||
const locationParams = new URLSearchParams(location.search);
|
||||
const token = locationParams.get('token');
|
||||
|
||||
for (const preset of urlPresets) {
|
||||
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
|
||||
* /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
|
||||
*/
|
||||
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
|
||||
* this means we will not redirect
|
||||
@@ -114,6 +118,30 @@ export function generatePathFromPreset(
|
||||
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
|
||||
* 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 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) {
|
||||
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('n');
|
||||
newUrl.searchParams.delete('token');
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ ul {
|
||||
|
||||
.tabs {
|
||||
width: min(30vw, 300px);
|
||||
flex: 0 0 min(30vw, 300px);
|
||||
min-width: min(30vw, 300px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
+10
@@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [
|
||||
'{{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
|
||||
* Based on RuntimeState
|
||||
@@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...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 { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||
|
||||
export type NamedImportMap = typeof namedImportMap;
|
||||
|
||||
// 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) {
|
||||
localStorage.setItem('import-options', JSON.stringify(options));
|
||||
localStorage.setItem(importMapKey, JSON.stringify(options));
|
||||
}
|
||||
|
||||
function getPersistImportMap(): unknown {
|
||||
const options = localStorage.getItem('import-options');
|
||||
const options = localStorage.getItem(importMapKey);
|
||||
if (!options) {
|
||||
throw new Error('no import options found');
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
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>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<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.Field
|
||||
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 { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
|
||||
return (
|
||||
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useAppVersion from '../../common/hooks-query/useAppVersion';
|
||||
import { isDocker } from '../../externals';
|
||||
|
||||
export type SettingsOption = {
|
||||
id: string;
|
||||
@@ -18,6 +19,7 @@ const staticOptions = [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__port', label: 'Server Port' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -102,6 +104,14 @@ export function useAppSettingsMenu() {
|
||||
() =>
|
||||
staticOptions.map((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,
|
||||
})),
|
||||
[data],
|
||||
|
||||
@@ -42,7 +42,11 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
<div className={style.indicatorNegative} data-active={isOvertime} />
|
||||
<Tooltip text={addedTimeLabel} render={<div />} className={style.indicatorDelay} data-active={hasAddedTime} />
|
||||
</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}>
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CSSProperties, memo, RefObject, SyntheticEvent } from 'react';
|
||||
import { Day } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import DelayIndicator from '../../../common/components/delay-indicator/DelayIndicator';
|
||||
@@ -20,7 +21,7 @@ interface OperatorEventProps {
|
||||
timeStart: number;
|
||||
duration: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isLinkedToLoaded: boolean;
|
||||
isSelected: boolean;
|
||||
isPast: boolean;
|
||||
@@ -138,7 +139,7 @@ interface OperatorEventScheduleProps {
|
||||
isPast: boolean;
|
||||
isSelected: boolean;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
@@ -173,7 +174,7 @@ function OperatorEventSchedule({
|
||||
interface TimeUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { TbFlagFilled } from 'react-icons/tb';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
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 { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
@@ -55,7 +55,7 @@ interface RundownEventProps {
|
||||
isRolling: boolean;
|
||||
gap: number;
|
||||
isNextDay: boolean;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
hasTriggers: boolean;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
IoTime,
|
||||
} from 'react-icons/io5';
|
||||
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 { cx } from '../../../common/utils/styleUtils';
|
||||
@@ -44,7 +44,7 @@ interface RundownEventInnerProps {
|
||||
loaded: boolean;
|
||||
playback?: Playback;
|
||||
isRolling: boolean;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isPast: boolean;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { IoCheckmarkCircle } from 'react-icons/io5';
|
||||
import { Day } from 'ontime-types';
|
||||
import { isPlaybackActive, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
@@ -14,7 +15,7 @@ interface RundownEventChipProps {
|
||||
id: string;
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
isPast: boolean;
|
||||
isLoaded: boolean;
|
||||
className: string;
|
||||
@@ -68,7 +69,7 @@ export default function RundownEventChip({
|
||||
interface EventUntilProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -1,49 +1,19 @@
|
||||
import { memo, use, useEffect, useMemo } from 'react';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useCuesheetPermissions } from './useTablePermissions';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const preset = use(PresetContext);
|
||||
|
||||
// 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 { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
@@ -54,7 +24,16 @@ function CuesheetTableWrapper() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
interface CuesheetTableProps {
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
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 { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
|
||||
@@ -206,17 +217,25 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot = 'cues
|
||||
return <EmptyPage text='Loading...' />;
|
||||
}
|
||||
|
||||
// control components need different implementations for handling permissions
|
||||
const TableRootSettings = tableRoot === 'editor' ? EditorTableSettings : CuesheetTableSettings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRootSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
{tableRoot === 'editor' ? (
|
||||
<EditorTableSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
) : (
|
||||
<CuesheetTableSettings
|
||||
columns={allLeafColumns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
|
||||
+2
-9
@@ -7,6 +7,7 @@ import DelayIndicator from '../../../../common/components/delay-indicator/DelayI
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { formatDuration, formatTime } from '../../../../common/utils/time';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
|
||||
|
||||
import DurationInput from './DurationInput';
|
||||
import EditableImage from './EditableImage';
|
||||
@@ -257,15 +258,7 @@ export function makeCuesheetColumns(
|
||||
preset: URLPreset | undefined,
|
||||
): ColumnDef<ExtendedEntry>[] {
|
||||
const columnsDef: ColumnDef<ExtendedEntry>[] = [];
|
||||
const modeAllowsWrite = cuesheetMode === AppMode.Edit;
|
||||
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));
|
||||
const { canRead, canWrite } = getCuesheetColumnAccessPolicy(preset, cuesheetMode);
|
||||
|
||||
if (canRead('flag')) {
|
||||
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 { Popover } from '@base-ui/react/popover';
|
||||
import { Toggle } from '@base-ui/react/toggle';
|
||||
import { ToggleGroup } from '@base-ui/react/toggle-group';
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import { PresetContext } from '../../../../common/context/PresetContext';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode, sessionKeys } from '../../../../ontimeConfig';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
|
||||
@@ -24,6 +22,8 @@ import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
@@ -42,19 +42,16 @@ export interface ColumnSettingsProps {
|
||||
|
||||
export default function CuesheetTableSettings({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: CuesheetTableSettingsProps) {
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||
const preset = use(PresetContext);
|
||||
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[]) => {
|
||||
// we need to stop user from deselecting a mode
|
||||
const newValue = mode.at(0);
|
||||
@@ -71,14 +68,20 @@ export default function CuesheetTableSettings({
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
<ToggleGroup value={[cuesheetMode]} onValueChange={toggleCuesheetMode} className={cx([style.group, style.apart])}>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
{canChangeMode && (
|
||||
<ToggleGroup
|
||||
value={[cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={cx([style.group, style.apart])}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
|
||||
{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;
|
||||
canFlag: 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) => ({
|
||||
canChangeMode: false,
|
||||
canCreateEntries: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
@@ -78,7 +78,7 @@ function TitleListItem({
|
||||
interface TitleListTimeUntilChipProps {
|
||||
timeStart: number;
|
||||
delay: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
isLoaded: boolean;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RefObject } from 'react';
|
||||
import { Day } from 'ontime-types';
|
||||
|
||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
@@ -20,7 +21,7 @@ interface TimelineEntryProps {
|
||||
left: number;
|
||||
status: ProgressStatus;
|
||||
start: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
title: string;
|
||||
@@ -119,7 +120,7 @@ export function TimelineEntry({
|
||||
interface TimelineEntryStatusProps {
|
||||
delay: number;
|
||||
start: number;
|
||||
dayOffset: number;
|
||||
dayOffset: Day;
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
status: ProgressStatus;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||
|
||||
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.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 { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
@@ -40,9 +40,20 @@ describe('triggerAction()', () => {
|
||||
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
|
||||
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 () => {
|
||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
|
||||
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { });
|
||||
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { });
|
||||
|
||||
await deleteAllTriggers();
|
||||
const oscAutomation = await addAutomation({
|
||||
@@ -70,26 +81,25 @@ describe('triggerAction()', () => {
|
||||
});
|
||||
|
||||
it('should trigger automations for a given action', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
triggerAutomations(TimerLifeCycle.onLoad, state);
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
expect(oscSpy).toHaveBeenCalledTimes(1);
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onStart, state);
|
||||
triggerAutomations(TimerLifeCycle.onStart);
|
||||
expect(oscClient.emitOSC).not.toBeCalled();
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onFinish, state);
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
expect(oscSpy).not.toBeCalled();
|
||||
expect(httpSpy).toHaveBeenCalledTimes(1);
|
||||
oscSpy.mockReset();
|
||||
httpSpy.mockReset();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onStop, state);
|
||||
triggerAutomations(TimerLifeCycle.onStop);
|
||||
expect(oscSpy).not.toBeCalled();
|
||||
expect(httpSpy).not.toBeCalled();
|
||||
});
|
||||
@@ -540,7 +550,7 @@ describe('testConditions()', () => {
|
||||
|
||||
describe('for all filter rule', () => {
|
||||
it('should return true when all filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -560,7 +570,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return false if any filters are false', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -582,7 +592,7 @@ describe('testConditions()', () => {
|
||||
|
||||
describe('for any filter rule', () => {
|
||||
it('should return true when all filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'test',
|
||||
@@ -602,7 +612,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return true if any filters are true', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({
|
||||
title: 'not-test',
|
||||
@@ -622,7 +632,7 @@ describe('testConditions()', () => {
|
||||
});
|
||||
|
||||
it('should return false if all filters are false', () => {
|
||||
const mockStore = makeRuntimeStateData({
|
||||
const mockStore = makeRuntimeStoreData({
|
||||
clock: 10,
|
||||
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
isOntimeAction,
|
||||
isOSCOutput,
|
||||
LogOrigin,
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
@@ -10,29 +11,30 @@ import {
|
||||
} from 'ontime-types';
|
||||
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 { emitHTTP } from './clients/http.client.js';
|
||||
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.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
|
||||
*/
|
||||
export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) {
|
||||
export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
if (!getAutomationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
const store = eventStore.poll();
|
||||
|
||||
let triggers = getAutomationTriggers();
|
||||
|
||||
// get triggers from event
|
||||
if (state.eventNow?.triggers) {
|
||||
triggers = triggers.concat(state.eventNow.triggers);
|
||||
if (store.eventNow?.triggers) {
|
||||
triggers = triggers.concat(store.eventNow.triggers);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, state);
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
|
||||
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
|
||||
*/
|
||||
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(
|
||||
filters: AutomationFilter[],
|
||||
filterRule: FilterRule,
|
||||
state: Partial<RuntimeState>,
|
||||
store: Partial<RuntimeStore>,
|
||||
): boolean {
|
||||
if (filters.length === 0) {
|
||||
return true;
|
||||
@@ -86,7 +89,7 @@ export function testConditions(
|
||||
function evaluateCondition(filter: AutomationFilter): boolean {
|
||||
const { field, operator, value } = filter;
|
||||
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
|
||||
// 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
|
||||
* Returns a boolean indicating whether a message was sent
|
||||
*/
|
||||
function send(output: AutomationOutput[], state?: RuntimeState) {
|
||||
const stateSnapshot = state ?? getState();
|
||||
function send(output: AutomationOutput[], store: RuntimeStore) {
|
||||
output.forEach((payload) => {
|
||||
if (isOSCOutput(payload) && !isOntimeCloud) {
|
||||
emitOSC(payload, stateSnapshot);
|
||||
emitOSC(payload, store);
|
||||
} else if (isHTTPOutput(payload)) {
|
||||
emitHTTP(payload, stateSnapshot);
|
||||
emitHTTP(payload, store);
|
||||
} else if (isOntimeAction(payload)) {
|
||||
toOntimeAction(payload);
|
||||
} 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 type { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
|
||||
import { parseTemplateNested } from '../automation.utils.js';
|
||||
|
||||
/**
|
||||
* Expose possibility to send a message using HTTP protocol
|
||||
*/
|
||||
export function emitHTTP(output: HTTPOutput, state: RuntimeState) {
|
||||
const url = preparePayload(output, state);
|
||||
export function emitHTTP(output: HTTPOutput, store: DeepReadonly<RuntimeStore>) {
|
||||
const url = preparePayload(output, store);
|
||||
emit(url);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
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 * as dgram from 'node:dgram';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { type RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
const udpClient = dgram.createSocket('udp4');
|
||||
|
||||
/**
|
||||
* Expose possibility to send a message using OSC protocol
|
||||
*/
|
||||
export function emitOSC(output: OSCOutput, state: RuntimeState) {
|
||||
const message = preparePayload(output, state);
|
||||
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
||||
const message = preparePayload(output, store);
|
||||
emit(output.targetIP, output.targetPort, message);
|
||||
}
|
||||
|
||||
/** 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
|
||||
const parsedAddress = parseTemplateNested(output.address, state);
|
||||
const parsedAddress = parseTemplateNested(output.address, store);
|
||||
|
||||
// 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
|
||||
const oscArguments = stringToOSCArgs(parsedArguments);
|
||||
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 { parseCustomFields } from '../custom-fields/customFields.parser.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 = {
|
||||
context: string;
|
||||
@@ -21,21 +24,45 @@ type ParsingError = {
|
||||
* @param {object} jsonData - project file to be parsed
|
||||
* @returns {object} parsed object
|
||||
*/
|
||||
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
||||
export function parseDatabaseModel(
|
||||
jsonData: Partial<DatabaseModel>,
|
||||
initialLoad = false,
|
||||
): {
|
||||
data: DatabaseModel;
|
||||
errors: ParsingError[];
|
||||
migrated: boolean;
|
||||
} {
|
||||
|
||||
let migrated = false;
|
||||
let migratedData = jsonData;
|
||||
const errors: ParsingError[] = [];
|
||||
|
||||
if (v3.shouldUseThisMigration(jsonData)) {
|
||||
try {
|
||||
migrated = true;
|
||||
logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate');
|
||||
migratedData = v3.migrateAllData(jsonData);
|
||||
} catch (_error) {
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Server, 'Failed to migrate the data');
|
||||
errors.push({ context: 'v3 migration', message: getErrorMessage(error) });
|
||||
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
|
||||
const settings = parseSettings(migratedData);
|
||||
|
||||
const errors: ParsingError[] = [];
|
||||
const makeEmitError = (context: string) => (message: string) => {
|
||||
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
||||
errors.push({ context, message });
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Trigger,
|
||||
URLPreset,
|
||||
ViewSettings,
|
||||
Day,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
customFieldLabelToKey,
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { is } from '../../../utils/is.js';
|
||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
||||
import { getPartialProject } from '../../../models/dataModel.js';
|
||||
|
||||
// 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
|
||||
* - 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)) {
|
||||
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
||||
jsonData.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 ====! //
|
||||
revision: -1,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
});
|
||||
} 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 {
|
||||
AutomationSettings,
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
OntimeView,
|
||||
ProjectData,
|
||||
@@ -14,8 +15,7 @@ import {
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
import * as v3 from './db.migration.v3.js';
|
||||
|
||||
import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js';
|
||||
import * as v4 from './db.migration.v4.js';
|
||||
|
||||
describe('v3 to v4', () => {
|
||||
const oldDb = {
|
||||
@@ -175,8 +175,8 @@ describe('v3 to v4', () => {
|
||||
};
|
||||
|
||||
test('migrate settings', () => {
|
||||
const expectSettings: Settings = {
|
||||
version: ONTIME_VERSION,
|
||||
const expectSettings: Settings & { serverPort: number } = {
|
||||
version: '4.0.0',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isOntimeMilestone,
|
||||
OntimeMilestone,
|
||||
OntimeGroup,
|
||||
Day,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
isObjectEmpty,
|
||||
@@ -281,7 +282,7 @@ function processEntry<T extends OntimeEntry>(
|
||||
sanitiseCustomFields(customFields, currentEntry);
|
||||
|
||||
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.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.parent = childOfGroup;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password, routerPrefix } from '../../externals.js';
|
||||
import { hashPassword } from '../../utils/hash.js';
|
||||
import { ONTIME_VERSION } from '../../ONTIME_VERSION.js';
|
||||
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||
|
||||
const startedAt = new Date();
|
||||
|
||||
@@ -37,7 +38,8 @@ export async function getSessionStats(): Promise<SessionStats> {
|
||||
* Adds business logic to gathering data for the info endpoint
|
||||
*/
|
||||
export async function getInfo(): Promise<GetInfo> {
|
||||
const { version, serverPort } = getDataProvider().getSettings();
|
||||
const { version } = getDataProvider().getSettings();
|
||||
const { port } = portManager.getPort();
|
||||
|
||||
// get nif and inject localhost
|
||||
const ni = getNetworkInterfaces();
|
||||
@@ -46,7 +48,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
return {
|
||||
networkInterfaces: ni,
|
||||
version,
|
||||
serverPort,
|
||||
serverPort: port,
|
||||
publicDir: publicDir.root,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ describe('parseSettings()', () => {
|
||||
expect(result).toBeTypeOf('object');
|
||||
expect(result).toMatchObject({
|
||||
version: expect.any(String),
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
||||
|
||||
return {
|
||||
version: defaultSettings.version,
|
||||
serverPort: data.settings.serverPort ?? defaultSettings.serverPort,
|
||||
editorKey: data.settings.editorKey ?? defaultSettings.editorKey,
|
||||
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
||||
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import express from 'express';
|
||||
import { matchedData } from 'express-validator';
|
||||
import type { Request, Response } from 'express';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import express from "express";
|
||||
import { matchedData } from "express-validator";
|
||||
import type { Request, Response } from "express";
|
||||
import { deepEqual } from "fast-equals";
|
||||
|
||||
import { ErrorResponse, RefetchKey, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types";
|
||||
import { getErrorMessage, obfuscate } from "ontime-utils";
|
||||
|
||||
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
import { isDocker } from '../../setup/environment.js';
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import {
|
||||
validateSettings,
|
||||
validateWelcomeDialog,
|
||||
validateServerPort,
|
||||
} 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();
|
||||
|
||||
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);
|
||||
res.status(200).json({ show });
|
||||
});
|
||||
|
||||
router.get('/', (_req: Request, res: Response<Settings>) => {
|
||||
router.get("/", (_req: Request, res: Response<Settings>) => {
|
||||
const settings = getDataProvider().getSettings();
|
||||
const obfuscatedSettings = { ...settings };
|
||||
if (settings.editorKey) {
|
||||
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
|
||||
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 {
|
||||
const data = matchedData<Settings>(req);
|
||||
const settings = getDataProvider().getSettings();
|
||||
|
||||
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);
|
||||
const { port, pendingRestart } = portManager.getPort();
|
||||
res.status(200).json({ port, pendingRestart });
|
||||
} catch (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'),
|
||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||
body('language').isString().trim().notEmpty(),
|
||||
body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(),
|
||||
|
||||
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>) => {
|
||||
try {
|
||||
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 = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// 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 { generateCrashReport } from './utils/generateCrashReport.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');
|
||||
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 }> => {
|
||||
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
|
||||
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
|
||||
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
|
||||
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
||||
expressServer = http.createServer(app);
|
||||
const resultPort = await portManager.attachServer(expressServer);
|
||||
|
||||
const showWelcome = await getShowWelcomeDialog(!!restorePoint);
|
||||
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
|
||||
if (exitCode === 0 || exitCode === 99) {
|
||||
await restoreService.clear();
|
||||
await portManager.shutdown();
|
||||
}
|
||||
|
||||
expressServer?.close();
|
||||
|
||||
@@ -72,14 +72,12 @@ describe('safeMerge', () => {
|
||||
it('merges the settings key', () => {
|
||||
const mergedData = safeMerge(baseDb, {
|
||||
settings: {
|
||||
serverPort: 3000,
|
||||
language: 'pt',
|
||||
version: 'new',
|
||||
} as Settings,
|
||||
});
|
||||
expect(mergedData.settings).toStrictEqual({
|
||||
version: 'new',
|
||||
serverPort: 3000,
|
||||
operatorKey: null,
|
||||
editorKey: null,
|
||||
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: {
|
||||
version: ONTIME_VERSION,
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -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 = {
|
||||
rundowns: {
|
||||
@@ -66,7 +66,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Music plays, holding slide on screens',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1',
|
||||
parent: '7eaf99',
|
||||
@@ -109,7 +109,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Emma Thompson',
|
||||
colour: '#FFCC78',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1.1',
|
||||
parent: '7eaf99',
|
||||
@@ -141,7 +141,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Liam Carter, Sophia Patel + PowerPoint',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '1.2',
|
||||
parent: '7eaf99',
|
||||
@@ -199,7 +199,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Buffet in lobby',
|
||||
colour: '#779BE7',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '2.1',
|
||||
parent: 'f60403',
|
||||
@@ -257,7 +257,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Ethan Brooks + PowerPoint + Video playback',
|
||||
colour: '#77C785',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '3.1',
|
||||
parent: '6b0edb',
|
||||
@@ -289,7 +289,7 @@ export const demoDb: DatabaseModel = {
|
||||
note: 'Lucas Bennett',
|
||||
colour: '#FFCC78',
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
dayOffset: 0 as Day,
|
||||
gap: 0,
|
||||
cue: '3.2',
|
||||
parent: '6b0edb',
|
||||
@@ -336,7 +336,6 @@ export const demoDb: DatabaseModel = {
|
||||
},
|
||||
settings: {
|
||||
version: '-',
|
||||
serverPort: 4001,
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
|
||||
@@ -709,6 +709,44 @@ describe('loadRoll() test that roll behaviour multi day event edge cases', () =>
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
await initRundown(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { dayInMs, MILLIS_PER_HOUR, millisToString } from 'ontime-utils';
|
||||
import { EndAction, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, millisToString } from 'ontime-utils';
|
||||
import { EndAction, Playback, TimeOfDay, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import {
|
||||
findDayOffset,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getExpectedFinish,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
hasCrossedMidnight,
|
||||
normaliseEndTime,
|
||||
skippedOutOfEvent,
|
||||
} 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()', () => {
|
||||
const testSkipLimit = 32;
|
||||
it('does not consider an event end as a skip', () => {
|
||||
@@ -693,6 +713,44 @@ describe('skippedOutOfEvent()', () => {
|
||||
state.clock -= testSkipLimit + 1;
|
||||
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()', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AppState {
|
||||
projectName?: string;
|
||||
rundownId?: string;
|
||||
showWelcomeDialog?: boolean;
|
||||
serverPort?: number;
|
||||
}
|
||||
|
||||
const adapter = new JSONFile<AppState>(publicFiles.appState);
|
||||
@@ -61,3 +62,13 @@ export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
|
||||
await config.write();
|
||||
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 { withoutUndefinedValues } from 'ontime-utils';
|
||||
import { DeepPartial } from 'ts-essentials';
|
||||
|
||||
import { throttle } from '../../utils/throttle.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
|
||||
|
||||
@@ -183,7 +183,10 @@ export async function initialiseProject(): Promise<string> {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectName = await loadProjectFile(lastLoaded.projectName, lastLoaded.rundownId);
|
||||
const projectName = await loadProjectFile(lastLoaded.projectName, {
|
||||
rundownId: lastLoaded.rundownId,
|
||||
initialLoad: true,
|
||||
});
|
||||
return projectName;
|
||||
} catch (error) {
|
||||
// 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
|
||||
* @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);
|
||||
if (filePath === null) {
|
||||
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
|
||||
const fileData = await parseJsonFile(filePath);
|
||||
const result = parseDatabaseModel(fileData);
|
||||
const result = parseDatabaseModel(fileData, options?.initialLoad);
|
||||
let parsedFileName = fileName;
|
||||
|
||||
if (result.migrated) {
|
||||
@@ -226,7 +232,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro
|
||||
parsedFileName = await handleCorruptedFile(filePath, parsedFileName);
|
||||
}
|
||||
|
||||
const projectName = await loadProject(result.data, parsedFileName, rundownId);
|
||||
const projectName = await loadProject(result.data, parsedFileName, options?.rundownId);
|
||||
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 = {
|
||||
playback: Playback;
|
||||
@@ -7,6 +7,6 @@ export type RestorePoint = {
|
||||
addedTime: number;
|
||||
pausedAt: MaybeNumber;
|
||||
firstStart: MaybeNumber;
|
||||
startEpoch: MaybeNumber;
|
||||
startEpoch: Maybe<Instant>;
|
||||
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 { normaliseEndTime } from './timerUtils.js';
|
||||
@@ -36,11 +36,6 @@ export function loadRoll(
|
||||
continue;
|
||||
}
|
||||
|
||||
// we check if event crosses midnight
|
||||
if (event.timeStart > event.timeEnd) {
|
||||
daySpan++;
|
||||
}
|
||||
|
||||
const correctedDays = dayInMs * daySpan;
|
||||
const correctedStart = event.timeStart + correctedDays;
|
||||
const correctedEnd = event.timeEnd + correctedDays;
|
||||
@@ -49,13 +44,18 @@ export function loadRoll(
|
||||
* there are 3 possible states for an event
|
||||
* 1. event is already finished
|
||||
* 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
|
||||
// when does the event end (handle midnight)
|
||||
const normalEnd = normaliseEndTime(correctedStart, correctedEnd);
|
||||
if (normalEnd <= timeNow) {
|
||||
// keep track of day progress
|
||||
if (event.timeStart > event.timeEnd) {
|
||||
daySpan++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -66,8 +66,25 @@ export function loadRoll(
|
||||
return { event, index: getTimedIndexFromPlayableIndex(metadata, i) };
|
||||
}
|
||||
|
||||
// 3. event will run in the future
|
||||
// we set the isPending flag to indicate that the event is currently playing
|
||||
// 3. check if an overnight event that comes later 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,11 @@ class RuntimeService {
|
||||
if (timerPhaseChanged) {
|
||||
if (newState.timer.phase === TimerPhase.Warning) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onWarning, newState);
|
||||
triggerAutomations(TimerLifeCycle.onWarning);
|
||||
});
|
||||
} else if (newState.timer.phase === TimerPhase.Danger) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onDanger, newState);
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class RuntimeService {
|
||||
} else if (hasTimerFinished) {
|
||||
// if the timer has finished, we need to load next and keep rolling
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
});
|
||||
this.handleLoadNext();
|
||||
this.rollLoaded(newState.offset);
|
||||
@@ -116,7 +116,7 @@ class RuntimeService {
|
||||
// 3. find if we need to process actions related to the timer finishing
|
||||
if (newState.timer.playback === Playback.Play && hasTimerFinished) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onFinish, newState);
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
});
|
||||
|
||||
// handle end action if there was a timer playing
|
||||
@@ -134,7 +134,7 @@ class RuntimeService {
|
||||
const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
if (shouldUpdateTimer) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onUpdate, newState);
|
||||
triggerAutomations(TimerLifeCycle.onUpdate);
|
||||
});
|
||||
|
||||
this.lastIntegrationTimerValue = newState.timer.current ?? -1;
|
||||
@@ -144,7 +144,7 @@ class RuntimeService {
|
||||
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
||||
if (shouldUpdateClock) {
|
||||
process.nextTick(() => {
|
||||
triggerAutomations(TimerLifeCycle.onClock, newState);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
});
|
||||
|
||||
this.lastIntegrationClockUpdate = newState.clock;
|
||||
@@ -209,10 +209,9 @@ class RuntimeService {
|
||||
|
||||
if (success) {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
|
||||
const newState = runtimeState.getState();
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
});
|
||||
}
|
||||
return success;
|
||||
@@ -447,7 +446,7 @@ class RuntimeService {
|
||||
if (didStart) {
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart);
|
||||
});
|
||||
}
|
||||
return didStart;
|
||||
@@ -500,7 +499,7 @@ class RuntimeService {
|
||||
const newState = runtimeState.getState();
|
||||
logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`);
|
||||
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()}`);
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onStop, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStop);
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -548,7 +547,14 @@ class RuntimeService {
|
||||
const metadata = getRundownMetadata();
|
||||
|
||||
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) {
|
||||
logger.error(LogOrigin.Server, `Roll: ${error}`);
|
||||
}
|
||||
@@ -577,14 +583,14 @@ class RuntimeService {
|
||||
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStop, previousState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad, newState);
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.didStart) {
|
||||
process.nextTick(() => {
|
||||
triggerReportEntry(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart, newState);
|
||||
triggerAutomations(TimerLifeCycle.onStart);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaybeNumber, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { checkIsNow, dayInMs, isPlaybackActive, MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param {RuntimeState} state runtime state
|
||||
@@ -98,14 +107,15 @@ export function skippedOutOfEvent(state: RuntimeState, previousTime: number, ski
|
||||
const { startedAt, expectedFinish } = state.timer;
|
||||
const { clock } = state;
|
||||
|
||||
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
|
||||
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
|
||||
const isInsideEvent = checkIsNow(startedAt, expectedFinish, clock);
|
||||
if (isInsideEvent) return false;
|
||||
|
||||
const timeDifference = previousTime - adjustedClock;
|
||||
const hasSkipped = Math.abs(timeDifference) > skipLimit;
|
||||
const adjustedExpectedFinish = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
|
||||
// we are outside the event, but we need to check if we skipped or just finished normally
|
||||
const timeFromPrevious = Math.abs(previousTime - clock);
|
||||
// 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
|
||||
* 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;
|
||||
if (distance >= 12 * MILLIS_PER_HOUR) return -1;
|
||||
if (distance < -12 * MILLIS_PER_HOUR) return 1;
|
||||
return 0;
|
||||
if (distance >= 12 * MILLIS_PER_HOUR) return -1 as Day;
|
||||
if (distance < -12 * MILLIS_PER_HOUR) return 1 as Day;
|
||||
return 0 as Day;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const config = {
|
||||
crash: 'crash logs',
|
||||
demoProject: 'demo project.json',
|
||||
newProject: 'new project.json',
|
||||
defaultServerPort: 4001,
|
||||
database: {
|
||||
directory: 'db',
|
||||
filename: 'db.json',
|
||||
|
||||
@@ -5,4 +5,6 @@ export const isTest = Boolean(process.env.IS_TEST);
|
||||
export const environment = isTest ? 'test' : env;
|
||||
export const isDocker = env === 'docker';
|
||||
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 type { RuntimeState } from '../runtimeState.js';
|
||||
|
||||
const baseState: RuntimeState = {
|
||||
clock: 0,
|
||||
clock: 0 as TimeOfDay,
|
||||
eventNow: null,
|
||||
eventNext: 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 { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
@@ -256,8 +257,8 @@ describe('mutation on runtimeState', () => {
|
||||
order: ['event1'],
|
||||
});
|
||||
|
||||
const startEpoch = new Date('2024-01-01T00:00:00Z').getTime();
|
||||
vi.setSystemTime(new Date('2024-01-03T00:00:00Z'));
|
||||
const startEpoch = new Date('jan 1 00:00').getTime() as Instant;
|
||||
vi.setSystemTime('jan 3 23:59:59');
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
@@ -280,7 +281,8 @@ describe('mutation on runtimeState', () => {
|
||||
expect(newState.rundown.actualStart).toBe(60 * 1000);
|
||||
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();
|
||||
expect(getState().rundown.currentDay).toBe(3);
|
||||
});
|
||||
@@ -297,6 +299,437 @@ describe('roll mode', () => {
|
||||
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 () => {
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {
|
||||
Day,
|
||||
Duration,
|
||||
isOntimeEvent,
|
||||
Instant,
|
||||
MaybeNumber,
|
||||
MaybeString,
|
||||
OffsetMode,
|
||||
@@ -10,9 +13,11 @@ import {
|
||||
Rundown,
|
||||
Offset,
|
||||
runtimeStorePlaceholder,
|
||||
TimeOfDay,
|
||||
TimerPhase,
|
||||
TimerState,
|
||||
RundownState,
|
||||
Maybe,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
calculateDuration,
|
||||
@@ -23,7 +28,6 @@ import {
|
||||
isPlaybackActive,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { getTimeObject, timeNow } from '../utils/time.js';
|
||||
import type { RestorePoint } from '../services/restore-service/restore.type.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
@@ -31,16 +35,22 @@ import {
|
||||
getExpectedFinish,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
hasCrossedMidnight,
|
||||
} from '../services/timerUtils.js';
|
||||
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
import { RundownMetadata } from '../api-data/rundown/rundown.types.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 = {
|
||||
clock: number; // realtime clock
|
||||
clock: TimeOfDay;
|
||||
groupNow: OntimeGroup | null;
|
||||
eventNow: PlayableEvent | null;
|
||||
eventNext: PlayableEvent | null;
|
||||
@@ -50,9 +60,9 @@ export type RuntimeState = {
|
||||
rundown: RundownState;
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: MaybeNumber; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
};
|
||||
_rundown: {
|
||||
@@ -61,12 +71,12 @@ export type RuntimeState = {
|
||||
_group: ExpectedMetadata;
|
||||
_flag: ExpectedMetadata;
|
||||
_end: ExpectedMetadata;
|
||||
_startEpoch: MaybeNumber;
|
||||
_startDayOffset: MaybeNumber;
|
||||
_startEpoch: Maybe<Instant>;
|
||||
_startDayOffset: Maybe<Day>;
|
||||
};
|
||||
|
||||
const runtimeState: RuntimeState = {
|
||||
clock: timeNow(),
|
||||
clock: timeCore.timeOfDayNow(),
|
||||
groupNow: null,
|
||||
eventNow: null,
|
||||
eventNext: null,
|
||||
@@ -122,7 +132,7 @@ export function clearEventData() {
|
||||
runtimeState.rundown.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeNow();
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
@@ -154,7 +164,7 @@ export function clearState() {
|
||||
runtimeState._end = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = timeNow();
|
||||
runtimeState.clock = timeCore.timeOfDayNow();
|
||||
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
|
||||
|
||||
// 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.eventNow.timeStart < offsetClock && offsetClock < runtimeState.eventNow.timeEnd) {
|
||||
// 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;
|
||||
} 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;
|
||||
}
|
||||
|
||||
const [epoch, now] = getTimeObject();
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
|
||||
state.clock = now;
|
||||
state.timer.secondaryTimer = null;
|
||||
|
||||
@@ -423,7 +438,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
state.timer.elapsed = 0;
|
||||
|
||||
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._startEpoch = epoch;
|
||||
state.rundown.actualStart = state.clock;
|
||||
@@ -459,7 +474,7 @@ export function pause(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
|
||||
state.timer.playback = Playback.Pause;
|
||||
state.clock = timeNow();
|
||||
state.clock = timeCore.timeOfDayNow();
|
||||
state._timer.pausedAt = state.clock;
|
||||
return true;
|
||||
}
|
||||
@@ -494,7 +509,7 @@ export function addTime(amount: number) {
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeNow();
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
@@ -524,7 +539,8 @@ export type UpdateResult = {
|
||||
export function update(): UpdateResult {
|
||||
// 0. there are some things we always do
|
||||
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
|
||||
|
||||
// 1. is playback idle?
|
||||
@@ -532,16 +548,16 @@ export function update(): UpdateResult {
|
||||
return updateIfIdle();
|
||||
}
|
||||
|
||||
// if we are playing and playback changes. we tick the current runtime day
|
||||
if (runtimeState._startDayOffset !== null && runtimeState._startEpoch) {
|
||||
runtimeState.rundown.currentDay =
|
||||
runtimeState._startDayOffset + Math.floor((epoch - runtimeState._startEpoch) / dayInMs);
|
||||
// calculate currentDay from epoch (days elapsed since playback was started)
|
||||
if (runtimeState._startEpoch !== null && runtimeState._startDayOffset !== null) {
|
||||
const daysSinceStart = timeCore.daysSinceStart(runtimeState._startEpoch, epoch);
|
||||
runtimeState.rundown.currentDay = runtimeState._startDayOffset + daysSinceStart;
|
||||
}
|
||||
|
||||
// 2. are we waiting to roll?
|
||||
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
|
||||
const hasCrossedMidnight = previousClock > runtimeState.clock;
|
||||
return updateIfWaitingToRoll(hasCrossedMidnight);
|
||||
const clockHasCrossedMidnight = hasCrossedMidnight(previousClock, now);
|
||||
return updateIfWaitingToRoll(clockHasCrossedMidnight);
|
||||
}
|
||||
|
||||
// 3. at this point we know that we are playing an event
|
||||
@@ -600,11 +616,17 @@ export function update(): UpdateResult {
|
||||
if (hasCrossedMidnight) {
|
||||
// if we crossed midnight, we need to update the target
|
||||
// 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;
|
||||
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
|
||||
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget! - offsetClock;
|
||||
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
|
||||
const [epoch, now] = getTimeObject();
|
||||
const epoch = timeCore.now();
|
||||
const now = timeCore.toTimeOfDay(epoch);
|
||||
runtimeState.clock = now;
|
||||
|
||||
// 2. if there is an event armed, we use it
|
||||
@@ -668,13 +691,27 @@ export function roll(
|
||||
runtimeState.rundown.actualGroupStart = plannedStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* we need to backdate the actual start and start metadata
|
||||
* to prevent adding unintended offset
|
||||
*/
|
||||
if (runtimeState.rundown.actualStart === null) {
|
||||
runtimeState.rundown.actualStart = plannedStart;
|
||||
runtimeState._startDayOffset = 0;
|
||||
runtimeState._startEpoch = epoch;
|
||||
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||
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 {
|
||||
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.phase = TimerPhase.Pending;
|
||||
}
|
||||
@@ -720,12 +757,19 @@ export function roll(
|
||||
// there is nothing now, but something coming up
|
||||
runtimeState.timer.phase = TimerPhase.Pending;
|
||||
// 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;
|
||||
|
||||
// preload timer properties
|
||||
runtimeState.timer.duration = calculateDuration(runtimeState.eventNow.timeStart, runtimeState.eventNow.timeEnd);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -757,8 +801,15 @@ export function roll(
|
||||
}
|
||||
|
||||
// update metadata
|
||||
runtimeState._startDayOffset = 0;
|
||||
runtimeState._startEpoch = epoch;
|
||||
// use plannedStart (not clock) because actualStart is backdated to plannedStart
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import type { Server } from 'http';
|
||||
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
|
||||
@@ -33,84 +26,3 @@ export function getNetworkInterfaces(): { name: string; address: string }[] {
|
||||
|
||||
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();
|
||||
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": {
|
||||
"app": "ontime",
|
||||
"version": "-",
|
||||
"serverPort": 4001,
|
||||
"editorKey": null,
|
||||
"operatorKey": null,
|
||||
"timeFormat": "24",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "../../tsconfig.common.json",
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"composite": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"noImplicitReturns": false, //TODO: fix this
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
"include": [
|
||||
"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({
|
||||
test: {
|
||||
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();
|
||||
});
|
||||
|
||||
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 expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share...' }).click();
|
||||
|
||||
// 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 read').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-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
|
||||
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');
|
||||
|
||||
// verify the preset
|
||||
@@ -165,8 +174,9 @@ test.describe('Sharing from cuesheet', () => {
|
||||
await expect(page.getByTestId('navigation__toggle-settings')).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Share...' })).toBeHidden();
|
||||
|
||||
// check that we are locked and cannot edit
|
||||
await page.getByRole('button', { name: 'Edit' }).click();
|
||||
// mode toggle should not be rendered for readonly users
|
||||
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
|
||||
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();
|
||||
});
|
||||
|
||||
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 expect(page.getByTestId('cuesheet')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Share...' }).click();
|
||||
|
||||
// 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 read').click();
|
||||
await page.getByTestId('lockNav').click();
|
||||
@@ -202,7 +214,7 @@ test.describe('Sharing from cuesheet', () => {
|
||||
|
||||
// create and verify link
|
||||
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');
|
||||
|
||||
// verify the preset
|
||||
@@ -219,6 +231,9 @@ test.describe('Sharing from cuesheet', () => {
|
||||
|
||||
// Verify that the title is visible and editable
|
||||
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
|
||||
await expect(page.getByRole('cell', { name: 'Duration' })).toBeHidden();
|
||||
|
||||
Vendored
-1
@@ -477,7 +477,6 @@
|
||||
"settings": {
|
||||
"app": "ontime",
|
||||
"version": "-",
|
||||
"serverPort": 4001,
|
||||
"editorKey": null,
|
||||
"operatorKey": null,
|
||||
"timeFormat": "24",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.4.2",
|
||||
"version": "4.5.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -6,6 +6,11 @@ export type NetworkInterface = {
|
||||
address: string;
|
||||
};
|
||||
|
||||
export type PortInfo = {
|
||||
port: number;
|
||||
pendingRestart: boolean;
|
||||
};
|
||||
|
||||
export interface SessionStats {
|
||||
startedAt: string;
|
||||
connectedClients: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TimerType } from '../TimerType.type.js';
|
||||
import type { TimeStrategy } from '../TimeStrategy.type.js';
|
||||
import type { Trigger } from './Automation.type.js';
|
||||
import type { EntryCustomFields } from './CustomFields.type.js';
|
||||
import type { Day } from './Temporal.js';
|
||||
|
||||
export type EntryId = string;
|
||||
|
||||
@@ -77,7 +78,7 @@ export type OntimeEvent = OntimeBaseEvent & {
|
||||
// !==== RUNTIME METADATA ====! //
|
||||
revision: number;
|
||||
delay: number; // calculated at runtime
|
||||
dayOffset: number; // calculated at runtime
|
||||
dayOffset: Day; // calculated at runtime
|
||||
gap: number; // calculated at runtime
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TimeFormat } from './TimeFormat.type.js';
|
||||
|
||||
export type Settings = {
|
||||
version: string;
|
||||
serverPort: number;
|
||||
editorKey: null | string;
|
||||
operatorKey: null | string;
|
||||
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;
|
||||
alias: string;
|
||||
search: string;
|
||||
options: {
|
||||
read: string;
|
||||
write: string;
|
||||
options?: {
|
||||
read?: string;
|
||||
write?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export type { RundownEntries, Rundown, ProjectRundowns } from './definitions/cor
|
||||
export { TimeStrategy } from './definitions/TimeStrategy.type.js';
|
||||
export { TimerType } from './definitions/TimerType.type.js';
|
||||
|
||||
// ---> Core
|
||||
export type { Day, Duration, Instant, TimeOfDay } from './definitions/core/Temporal.js';
|
||||
|
||||
// ---> Report
|
||||
export type { OntimeReport, OntimeEventReport } from './definitions/core/Report.type.js';
|
||||
|
||||
@@ -76,6 +79,7 @@ export type {
|
||||
MessageResponse,
|
||||
SessionStats,
|
||||
ProjectLogoResponse,
|
||||
PortInfo,
|
||||
} from './api/ontime-controller/BackendResponse.type.js';
|
||||
export type {
|
||||
EventPostPayload,
|
||||
@@ -125,7 +129,7 @@ export {
|
||||
isOntimeAction,
|
||||
isTimerLifeCycle,
|
||||
} from './utils/guards.js';
|
||||
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||
export type { Maybe, MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||
|
||||
// Colour
|
||||
export type { RGBColour } from './definitions/Colour.type.js';
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export type MaybeNumber = number | 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', () => {
|
||||
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
|
||||
*/
|
||||
export function checkIsNow(timeStart: number, timeEnd: number, clock: number): boolean {
|
||||
const normalisedEnd = timeEnd < timeStart ? timeEnd + dayInMs : timeEnd;
|
||||
return timeStart <= clock && clock <= normalisedEnd;
|
||||
if (timeEnd < timeStart) {
|
||||
// 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';
|
||||
|
||||
export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
@@ -24,7 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
|
||||
parent: null,
|
||||
revision: 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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user