mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 23:49:15 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe96941737 | |||
| ee9ac492c3 | |||
| 4a0a174eb9 | |||
| 56257adcbd | |||
| b10c38514f | |||
| 80cb1f96e7 | |||
| 42147d4d5b | |||
| 7850358039 | |||
| 52cae1e22f | |||
| 48f9fb2609 | |||
| ce8a4d230b | |||
| 1553573b94 | |||
| 41a09bf5c3 | |||
| 5dbe7afae8 | |||
| 6633cfc869 | |||
| d127367df9 | |||
| b21ec41ffd | |||
| 66292bc0cf | |||
| b946b245c2 | |||
| c238147b75 |
@@ -54,3 +54,6 @@ ontime-data/
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
|
||||
# Claude Code metadata
|
||||
.claude/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+16
-16
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.3.0",
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -12,27 +12,27 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.18.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"zustand": "^5.0.9"
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useLocation } from 'react-router';
|
||||
import { isLocalhost, supportsFullscreen } from '../../../externals';
|
||||
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
|
||||
import { navigatorConstants } from '../../../viewerConfig';
|
||||
import useUrlPresets from '../../hooks-query/useUrlPresets';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useClientStore } from '../../stores/clientStore';
|
||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||
@@ -105,6 +106,8 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
{route.label}
|
||||
</ClientLink>
|
||||
))}
|
||||
|
||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||
</div>
|
||||
|
||||
{isLocalhost && (
|
||||
@@ -117,3 +120,27 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||
const location = useLocation();
|
||||
const { data: urlPresets } = useUrlPresets();
|
||||
const navPresets = urlPresets.filter((preset) => preset.enabled && preset.displayInNav);
|
||||
|
||||
if (navPresets.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className={style.separator} />
|
||||
{navPresets.map((preset) => (
|
||||
<ClientLink
|
||||
key={preset.alias}
|
||||
to={`preset/${preset.alias}`}
|
||||
current={location.pathname === `/preset/${preset.alias}`}
|
||||
postAction={isSmallScreen ? onClose : undefined}
|
||||
>
|
||||
{preset.alias}
|
||||
</ClientLink>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import Button from '../buttons/Button';
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyTableBody.module.scss';
|
||||
|
||||
interface EmptyTableBodyProps {
|
||||
text: string;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const text = getLocalizedString('common.no_data');
|
||||
return (
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||
<span className={style.text}>{text}</span>
|
||||
{handleAddNew && (
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
<IoAdd />
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TimeStrategy,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
MILLIS_PER_SECOND,
|
||||
@@ -82,6 +83,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
} = useEditorSettings();
|
||||
|
||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||
@@ -239,6 +241,14 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
|
||||
const parentId = resolveInsertParent(rundownData, newEntry);
|
||||
const maybeParent = parentId ? rundownData.entries[parentId] : null;
|
||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
||||
newEntry.colour = maybeParent.colour;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
@@ -254,6 +264,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
defaultTimeStrategy,
|
||||
inheritGroupColour,
|
||||
addEntryMutation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
|
||||
defaultDangerTime: string;
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
inheritGroupColour: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
inheritGroupColour: false,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
||||
editorSettingsDefaults.endAction,
|
||||
),
|
||||
inheritGroupColour: booleanFromLocalStorage(
|
||||
EditorSettingsKeys.InheritGroupColour,
|
||||
editorSettingsDefaults.inheritGroupColour,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
||||
return { defaultEndAction };
|
||||
}),
|
||||
setInheritGroupColour: (inheritGroupColour) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
||||
return { inheritGroupColour };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -37,6 +37,16 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'demopage',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
displayInNav: false,
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
const disabledPresets: URLPreset[] = [
|
||||
{
|
||||
enabled: false,
|
||||
alias: 'demopage',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
@@ -47,6 +57,16 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('checks if the current location matches an enabled preset target', () => {
|
||||
const location = resolvePath('/timer');
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('does not redirect disabled presets', () => {
|
||||
const location = resolvePath('/demopage');
|
||||
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when already on a preset path', () => {
|
||||
const location = resolvePath('/preset/demopage');
|
||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||
@@ -86,6 +106,11 @@ describe('getRouteFromPreset()', () => {
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
|
||||
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
|
||||
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cuesheet presets', () => {
|
||||
@@ -95,6 +120,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-4685d6',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
@@ -107,6 +133,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-basic',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
},
|
||||
];
|
||||
const cuesheetPresetWithNavLock: URLPreset[] = [
|
||||
@@ -115,6 +142,7 @@ describe('getRouteFromPreset()', () => {
|
||||
alias: 'cuesheet-locked',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: 'n=1',
|
||||
displayInNav: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -196,6 +224,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -207,6 +236,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -218,6 +248,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -229,6 +260,7 @@ describe('generateUrlPresetOptions', () => {
|
||||
target: 'timer',
|
||||
search: 'param1=value1¶m2=value2',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
])('should generate URL preset options for %s', (_description, alias, url, expected) => {
|
||||
|
||||
@@ -194,6 +194,7 @@ export function generateUrlPresetOptions(alias: string, userUrl: string): URLPre
|
||||
target: path,
|
||||
search: url.searchParams.toString(),
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { URLPreset } from 'ontime-types';
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoAdd, IoOpenOutline, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
@@ -22,13 +23,23 @@ const urlPresetsDocs = 'https://docs.getontime.no/features/url-presets/';
|
||||
|
||||
export default function URLPresets() {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, preset: undefined });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { data, status } = useUrlPresets();
|
||||
const { deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
const { updatePreset, deletePreset, isMutating } = useUpdateUrlPreset();
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
const openEditForm = (preset: URLPreset) => setFormState({ isOpen: true, preset });
|
||||
const closeForm = () => setFormState({ isOpen: false, preset: undefined });
|
||||
|
||||
const persistPreset = async (preset: URLPreset) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await updatePreset(preset.alias, preset);
|
||||
} catch (error) {
|
||||
setActionError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
@@ -53,10 +64,12 @@ export default function URLPresets() {
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{formState.isOpen && <URLPresetForm urlPreset={formState.preset} onClose={closeForm} />}
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Show in nav</th>
|
||||
<th>Target view</th>
|
||||
<th>Alias</th>
|
||||
<th />
|
||||
@@ -65,10 +78,23 @@ export default function URLPresets() {
|
||||
<tbody>
|
||||
{data.length === 0 && <Panel.TableEmpty handleClick={openNewForm} />}
|
||||
{data.map((preset, index) => {
|
||||
const isCuesheet = preset.target === OntimeView.Cuesheet;
|
||||
return (
|
||||
<tr key={preset.alias}>
|
||||
<td>
|
||||
<Switch defaultChecked={preset.enabled} onCheckedChange={() => {}} />
|
||||
<Switch
|
||||
checked={preset.enabled}
|
||||
onCheckedChange={(enabled) => persistPreset({ ...preset, enabled })}
|
||||
disabled={isMutating}
|
||||
aria-label='Toggle preset enabled'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Switch
|
||||
checked={preset.displayInNav}
|
||||
onCheckedChange={(checked) => persistPreset({ ...preset, displayInNav: checked })}
|
||||
disabled={isMutating || isCuesheet}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{preset.target}</Tag>
|
||||
|
||||
+88
-33
@@ -1,5 +1,5 @@
|
||||
import { OntimeView, OntimeViewPresettable, URLPreset } from 'ontime-types';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError, unwrapError } from '../../../../../common/api/utils';
|
||||
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './URLPresetForm.module.scss';
|
||||
@@ -32,6 +33,7 @@ const defaultValues: URLPreset = {
|
||||
target: OntimeView.Timer,
|
||||
search: '',
|
||||
enabled: true,
|
||||
displayInNav: false,
|
||||
};
|
||||
|
||||
interface URLPresetFormProps {
|
||||
@@ -61,12 +63,42 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
});
|
||||
const urlRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Cuesheet read/write permissions live outside react-hook-form
|
||||
const initialPermissions = useRef<CuesheetPermissionValues>({
|
||||
read: urlPreset?.options?.read ?? 'full',
|
||||
write: urlPreset?.options?.write ?? 'full',
|
||||
});
|
||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>(initialPermissions.current);
|
||||
|
||||
// update initial permissions on mount
|
||||
useEffect(() => {
|
||||
initialPermissions.current = {
|
||||
read: urlPreset?.options?.read ?? 'full',
|
||||
write: urlPreset?.options?.write ?? 'full',
|
||||
};
|
||||
setCuesheetPermissions(initialPermissions.current);
|
||||
// oxlint-disable-next-line eslint-plugin-react-hooks/exhaustive-deps -- run on mount
|
||||
}, []);
|
||||
|
||||
const isEditingCuesheet = urlPreset && urlPreset.target === OntimeView.Cuesheet;
|
||||
const isCuesheet = watch('target') === OntimeView.Cuesheet;
|
||||
const permissionsDirty =
|
||||
isCuesheet &&
|
||||
(cuesheetPermissions.read !== initialPermissions.current.read ||
|
||||
cuesheetPermissions.write !== initialPermissions.current.write);
|
||||
const noReadAccess = isCuesheet && cuesheetPermissions.read === '-';
|
||||
|
||||
const setupSubmit = async (data: URLPreset) => {
|
||||
try {
|
||||
// Preserve / apply cuesheet permissions, which are not part of the form fields
|
||||
const payload: URLPreset =
|
||||
data.target === OntimeView.Cuesheet
|
||||
? { ...data, target: OntimeView.Cuesheet, options: cuesheetPermissions }
|
||||
: data;
|
||||
if (urlPreset) {
|
||||
await updatePreset(urlPreset.alias, data);
|
||||
await updatePreset(urlPreset.alias, payload);
|
||||
} else {
|
||||
await addPreset(data);
|
||||
await addPreset(payload);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -74,6 +106,7 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
}
|
||||
};
|
||||
|
||||
// focus on alias when the form opens
|
||||
useEffect(() => {
|
||||
setFocus('alias');
|
||||
}, [setFocus]);
|
||||
@@ -126,44 +159,66 @@ export default function URLPresetForm({ urlPreset, onClose }: URLPresetFormProps
|
||||
<div className={style.expand}>
|
||||
<Panel.Description>Generate options (paste URL to generate options)</Panel.Description>
|
||||
<Panel.InlineElements>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} />
|
||||
<Button onClick={generateOptions}>Generate</Button>
|
||||
<Input placeholder='Paste URL' fluid ref={urlRef} disabled={isEditingCuesheet} />
|
||||
<Button onClick={generateOptions} disabled={isEditingCuesheet}>
|
||||
Generate
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</Panel.InlineElements>
|
||||
{errors.alias?.message && <Panel.Error>{errors.alias.message}</Panel.Error>}
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
{!isEditingCuesheet && (
|
||||
<>
|
||||
<div>
|
||||
{enDash} or {enDash}
|
||||
</div>
|
||||
<div>2. Choose a view and its parameters</div>
|
||||
<div>
|
||||
<Panel.Description>Target</Panel.Description>
|
||||
<Select
|
||||
options={targetOptions}
|
||||
{...register('target', { required: 'Target is required' })}
|
||||
value={watch('target')}
|
||||
onValueChange={(value: OntimeViewPresettable | null) => {
|
||||
if (value === null) return;
|
||||
setValue('target', value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Panel.Description>Parameters</Panel.Description>
|
||||
<Textarea
|
||||
fluid
|
||||
rows={3}
|
||||
{...register('search', {
|
||||
validate: validateParams,
|
||||
})}
|
||||
/>
|
||||
<Panel.Error>{errors.search?.message}</Panel.Error>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCuesheet && (
|
||||
<div>
|
||||
<Panel.Description>Permissions</Panel.Description>
|
||||
<CuesheetLinkOptions
|
||||
initialRead={initialPermissions.current.read}
|
||||
initialWrite={initialPermissions.current.write}
|
||||
onChange={setCuesheetPermissions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' disabled={!isValid || !isDirty} loading={isSubmitting || isMutating}>
|
||||
<Button
|
||||
variant='primary'
|
||||
type='submit'
|
||||
disabled={!isValid || (!isDirty && !permissionsDirty) || noReadAccess}
|
||||
loading={isSubmitting || isMutating}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
|
||||
@@ -157,7 +157,7 @@ export default function ManageRundowns() {
|
||||
</td>
|
||||
<td>
|
||||
<DropdownMenu
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
|
||||
setDangerTime,
|
||||
setDefaultTimerType,
|
||||
setDefaultEndAction,
|
||||
setInheritGroupColour,
|
||||
} = useEditorSettings((state) => state);
|
||||
|
||||
const durationInMs = parseUserTime(defaultDuration);
|
||||
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
|
||||
/>
|
||||
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Inherit group colour'
|
||||
description='Whether new events and milestones inherit the colour of their parent group'
|
||||
/>
|
||||
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function McpSection() {
|
||||
const { data: infoData } = useInfo();
|
||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||
|
||||
// generate url
|
||||
useEffect(() => {
|
||||
const baseUrl = (() => {
|
||||
if (isOntimeCloud) return serverURL;
|
||||
|
||||
// for local setups we prefer the localhost IP to avoid remote access
|
||||
if (infoData.networkInterfaces.length > 0) {
|
||||
return `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`;
|
||||
}
|
||||
return serverURL;
|
||||
})();
|
||||
|
||||
// we are reusing the endpoint, so locking config and nav have no effect
|
||||
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||
.then(setMcpEndpointUrl)
|
||||
.catch(() => {
|
||||
setMcpEndpointUrl('');
|
||||
});
|
||||
}, [infoData]);
|
||||
|
||||
const mcpClientConfig = mcpEndpointUrl
|
||||
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>MCP Server</Panel.SubHeader>
|
||||
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
|
||||
<Panel.Divider />
|
||||
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||
<Panel.Divider />
|
||||
<Panel.Field
|
||||
title='Client configuration snippet'
|
||||
description='Paste this into your AI agent settings under "mcpServers"'
|
||||
/>
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import McpSection from './McpSection';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
@@ -13,6 +14,7 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
|
||||
return (
|
||||
@@ -30,6 +32,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={customViewsRef}>
|
||||
<CustomViews />
|
||||
</div>
|
||||
<div ref={mcpRef}>
|
||||
<McpSection />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.footer {
|
||||
width: 100%;
|
||||
}
|
||||
+28
-14
@@ -1,5 +1,5 @@
|
||||
import { TranslationObject, langEn } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
@@ -10,6 +10,8 @@ import Modal from '../../../../../common/components/modal/Modal';
|
||||
import { useTranslation } from '../../../../../translation/TranslationProvider';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './CustomTranslationModal.module.scss';
|
||||
|
||||
interface CustomTranslationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -17,14 +19,7 @@ interface CustomTranslationModalProps {
|
||||
|
||||
export default function CustomTranslationModal({ isOpen, onClose }: CustomTranslationModalProps) {
|
||||
const { userTranslation, postUserTranslation } = useTranslation();
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(langEn).forEach((key) => {
|
||||
values[toFormKey(key)] = userTranslation[key as keyof TranslationObject] || '';
|
||||
});
|
||||
return values;
|
||||
}, [userTranslation]);
|
||||
const customTranslationFormValues = useMemo(() => toFormValues(userTranslation), [userTranslation]);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -33,13 +28,17 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
formState: { isSubmitting, isDirty, errors, isValid },
|
||||
setError,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
defaultValues: customTranslationFormValues,
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
// keep data up to date
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
reset(customTranslationFormValues);
|
||||
}
|
||||
}, [customTranslationFormValues, isOpen, reset]);
|
||||
|
||||
const onSubmit = async (formData: Record<string, string>) => {
|
||||
try {
|
||||
const translationData: Record<string, string> = {};
|
||||
@@ -54,6 +53,10 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
}
|
||||
};
|
||||
|
||||
const resetToEnglish = () => {
|
||||
reset(toFormValues(langEn), { keepDefaultValues: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title='Edit custom translations'
|
||||
@@ -84,9 +87,12 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<div>
|
||||
<div className={style.footer}>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.InlineElements align='apart'>
|
||||
<Button variant='ghosted' size='large' onClick={resetToEnglish} disabled={isSubmitting}>
|
||||
Reset to English
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button size='large' onClick={onClose}>
|
||||
Cancel
|
||||
@@ -109,6 +115,14 @@ export default function CustomTranslationModal({ isOpen, onClose }: CustomTransl
|
||||
);
|
||||
}
|
||||
|
||||
function toFormValues(translation: Partial<TranslationObject>) {
|
||||
const values: Record<string, string> = {};
|
||||
Object.keys(langEn).forEach((key) => {
|
||||
values[toFormKey(key)] = translation[key as keyof TranslationObject] || '';
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
function toFormKey(key: string) {
|
||||
return key.replace('.', '_');
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ const staticOptions = [
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__port', label: 'Server port' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -96,18 +97,12 @@ export function useAppSettingsMenu() {
|
||||
|
||||
const options: Readonly<SettingsOption[]> = useMemo(
|
||||
() =>
|
||||
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,
|
||||
staticOptions.map((option) =>
|
||||
// if there is an update then highlight the about setting
|
||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||
})),
|
||||
option.id === 'about' && data.hasUpdates
|
||||
? Object.assign({}, option, { highlight: 'New version available' })
|
||||
: option,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeView, URLPreset } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { FieldErrors, useForm } from 'react-hook-form';
|
||||
|
||||
import { generateUrl } from '../../common/api/session';
|
||||
@@ -18,7 +18,7 @@ import { preventEscape } from '../../common/utils/keyEvent';
|
||||
import { isUrlSafe } from '../../common/utils/regex';
|
||||
import { isOntimeCloud, serverURL } from '../../externals';
|
||||
import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
import CuesheetLinkOptions from './composite/CuesheetLinkOptions';
|
||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from './composite/CuesheetLinkOptions';
|
||||
|
||||
import style from './GenerateLinkForm.module.scss';
|
||||
|
||||
@@ -53,12 +53,26 @@ type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function GenerateLinkForm({ hostOptions, pathOptions, presets, isLockedToView }: GenerateLinkFormProps) {
|
||||
const [formState, setFormState] = useState<GenerateLinkState>('pending');
|
||||
const [url, setUrl] = useState(serverURL);
|
||||
const cuesheetReadRef = useRef<HTMLInputElement>(null);
|
||||
const cuesheetWriteRef = useRef<HTMLInputElement>(null);
|
||||
const [url, setUrl] = useState('');
|
||||
const [cuesheetPermissions, setCuesheetPermissions] = useState<CuesheetPermissionValues>({
|
||||
read: 'full',
|
||||
write: 'full',
|
||||
});
|
||||
const generatedAlias = useRef<string>(`cuesheet-${generateId()}`);
|
||||
|
||||
const { addPreset } = useUpdateUrlPreset();
|
||||
const { addPreset, updatePreset } = useUpdateUrlPreset();
|
||||
// Tracks the alias we already created this session so re-generating updates rather than duplicates it
|
||||
const createdAlias = useRef<string | null>(null);
|
||||
|
||||
/**
|
||||
* Permissions live outside react-hook-form, so we reset a successful state manually
|
||||
* whenever they change - this re-arms the "Create share link" button as the previous
|
||||
* link no longer reflects the selected permissions.
|
||||
*/
|
||||
const handlePermissionsChange = useCallback((permissions: CuesheetPermissionValues) => {
|
||||
setCuesheetPermissions(permissions);
|
||||
setFormState((current) => (current === 'success' ? 'pending' : current));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -90,16 +104,20 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
if (options.read === '-') {
|
||||
throw new Error('Cannot create a share with no read permissions');
|
||||
}
|
||||
const presets = await addPreset({
|
||||
const payload = {
|
||||
target: OntimeView.Cuesheet,
|
||||
enabled: true,
|
||||
alias,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: options.read,
|
||||
write: options.write,
|
||||
},
|
||||
});
|
||||
} as const;
|
||||
// Re-generating with the same name updates the existing preset instead of failing on a duplicate alias
|
||||
const presets = createdAlias.current === alias ? await updatePreset(alias, payload) : await addPreset(payload);
|
||||
createdAlias.current = alias;
|
||||
return presets.find((preset) => preset.alias === alias);
|
||||
};
|
||||
|
||||
@@ -108,8 +126,8 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
setFormState('loading');
|
||||
if (options.path === OntimeView.Cuesheet) {
|
||||
const urlPreset = await createPresetFromOptions((options as CuesheetLinkOptions).alias, {
|
||||
read: cuesheetReadRef.current?.value ?? 'full',
|
||||
write: cuesheetWriteRef.current?.value ?? 'full',
|
||||
read: cuesheetPermissions.read,
|
||||
write: cuesheetPermissions.write,
|
||||
});
|
||||
|
||||
if (!urlPreset) {
|
||||
@@ -157,6 +175,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
}
|
||||
};
|
||||
|
||||
const noReadAccess = watch('path') === OntimeView.Cuesheet && cuesheetPermissions.read === '-';
|
||||
const canSubmit = isDirty || formState !== 'success';
|
||||
|
||||
return (
|
||||
@@ -220,7 +239,7 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<CuesheetLinkOptions readRef={cuesheetReadRef} writeRef={cuesheetWriteRef} />
|
||||
<CuesheetLinkOptions onChange={handlePermissionsChange} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -268,18 +287,29 @@ export default function GenerateLinkForm({ hostOptions, pathOptions, presets, is
|
||||
</Panel.ListGroup>
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.InlineElements align='end' className={style.end}>
|
||||
<Button type='submit' variant={canSubmit ? 'primary' : 'subtle'} loading={formState === 'loading'}>
|
||||
<Button
|
||||
type='submit'
|
||||
variant={canSubmit ? 'primary' : 'subtle'}
|
||||
loading={formState === 'loading'}
|
||||
disabled={noReadAccess}
|
||||
>
|
||||
{canSubmit ? 'Create share link' : 'Link copied to clipboard!'}
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
<Panel.Section className={style.column}>
|
||||
<Panel.Description>Share this link</Panel.Description>
|
||||
<QRCode size={172} value={url} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||
{url ? (
|
||||
<>
|
||||
<QRCode size={172} value={url} />
|
||||
<div className={style.copiableLink} data-testid='copy-link'>
|
||||
{url}
|
||||
</div>
|
||||
<CopyTag copyValue={url}>Copy link</CopyTag>
|
||||
</>
|
||||
) : (
|
||||
<Panel.Description>Your link will appear here once you create it.</Panel.Description>
|
||||
)}
|
||||
</Panel.Section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, RefObject, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import RadioGroup from '../../../common/components/radio-group/RadioGroup';
|
||||
import Switch from '../../../common/components/switch/Switch';
|
||||
@@ -10,118 +10,141 @@ import style from './CuesheetLinkOptions.module.scss';
|
||||
|
||||
type AccessMode = 'full' | 'custom';
|
||||
|
||||
interface CuesheetLinkOptionsProps {
|
||||
readRef?: RefObject<HTMLInputElement | null>;
|
||||
writeRef?: RefObject<HTMLInputElement | null>;
|
||||
export interface CuesheetPermissionValues {
|
||||
read: string;
|
||||
write: string;
|
||||
}
|
||||
|
||||
export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkOptionsProps) {
|
||||
interface CuesheetLinkOptionsProps {
|
||||
/** Existing read permission to seed the form with ('full' | '-' | comma separated keys) */
|
||||
initialRead?: string;
|
||||
/** Existing write permission to seed the form with ('full' | '-' | comma separated keys) */
|
||||
initialWrite?: string;
|
||||
/** Notifies the parent whenever the resolved read/write permissions change */
|
||||
onChange: (permissions: CuesheetPermissionValues) => void;
|
||||
}
|
||||
|
||||
/** A null result means "full or unset" - there is no explicit per-column selection to seed */
|
||||
function parseKeys(permission: string | undefined): Set<string> | null {
|
||||
if (permission == null || permission === 'full') {
|
||||
return null;
|
||||
}
|
||||
if (permission === '-') {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(permission.split(','));
|
||||
}
|
||||
|
||||
function modeFromPermission(permission: string | undefined): AccessMode {
|
||||
return permission == null || permission === 'full' ? 'full' : 'custom';
|
||||
}
|
||||
|
||||
export default function CuesheetLinkOptions({ initialRead, initialWrite, onChange }: CuesheetLinkOptionsProps) {
|
||||
const { data } = useCustomFields();
|
||||
const customFieldColumns = useMemo(() => makeCuesheetCustomColumns(data), [data]);
|
||||
const allColumns = useMemo(() => [...cuesheetDefaultColumns, ...customFieldColumns], [customFieldColumns]);
|
||||
|
||||
const [readPermissions, setReadPermissions] = useState<AccessMode>('full');
|
||||
const [writePermissions, setWritePermissions] = useState<AccessMode>('full');
|
||||
// Parsed seed values - stable for the lifetime of a given preset
|
||||
const initialReadKeys = useMemo(() => parseKeys(initialRead), [initialRead]);
|
||||
const initialWriteKeys = useMemo(() => parseKeys(initialWrite), [initialWrite]);
|
||||
|
||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
initialState[column.value] = true;
|
||||
const [readPermissions, setReadPermissions] = useState<AccessMode>(() => modeFromPermission(initialRead));
|
||||
const [writePermissions, setWritePermissions] = useState<AccessMode>(() => modeFromPermission(initialWrite));
|
||||
|
||||
// Default for a column we have not seen yet: honour the seed in custom mode, otherwise grant access
|
||||
const defaultRead = useCallback(
|
||||
(key: string) => (initialReadKeys ? initialReadKeys.has(key) : true),
|
||||
[initialReadKeys],
|
||||
);
|
||||
const defaultWrite = useCallback(
|
||||
(key: string) => (initialWriteKeys ? initialWriteKeys.has(key) : true),
|
||||
[initialWriteKeys],
|
||||
);
|
||||
|
||||
const [readSwitches, setReadSwitches] = useState<Record<string, boolean>>({});
|
||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Custom fields load asynchronously, so reconcile the switch maps whenever the column list grows.
|
||||
// Newly seen columns are seeded from the initial values (or default to on for a fresh link).
|
||||
useEffect(() => {
|
||||
setReadSwitches((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const column of allColumns) {
|
||||
if (!(column.value in next)) next[column.value] = defaultRead(column.value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return initialState;
|
||||
});
|
||||
|
||||
const [writeSwitches, setWriteSwitches] = useState<Record<string, boolean>>(() => {
|
||||
const initialState: Record<string, boolean> = {};
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
initialState[column.value] = true;
|
||||
setWriteSwitches((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const column of allColumns) {
|
||||
if (!(column.value in next)) next[column.value] = defaultWrite(column.value);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return initialState;
|
||||
});
|
||||
}, [allColumns, defaultRead, defaultWrite]);
|
||||
|
||||
const isReadOn = (key: string) => readSwitches[key] ?? defaultRead(key);
|
||||
const isWriteOn = (key: string) => writeSwitches[key] ?? defaultWrite(key);
|
||||
|
||||
const handleReadModeChange = (value: AccessMode) => {
|
||||
setReadPermissions(value);
|
||||
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches };
|
||||
Object.keys(updatedReadSwitches).forEach((key) => {
|
||||
updatedReadSwitches[key] = true;
|
||||
});
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWriteModeChange = (value: AccessMode) => {
|
||||
setWritePermissions(value);
|
||||
// Full write implies full read
|
||||
if (value === 'full') {
|
||||
setReadPermissions('full');
|
||||
}
|
||||
setWritePermissions(value);
|
||||
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches };
|
||||
setWriteSwitches((prevWriteSwitches) => {
|
||||
const updatedWriteSwitches = { ...prevWriteSwitches };
|
||||
[...cuesheetDefaultColumns, ...customFieldColumns].forEach((column) => {
|
||||
updatedReadSwitches[column.value] = true;
|
||||
updatedWriteSwitches[column.value] = true;
|
||||
});
|
||||
return updatedWriteSwitches;
|
||||
});
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSwitchChange = (key: string, type: 'read' | 'write', value: boolean) => {
|
||||
if (type === 'read') {
|
||||
setReadSwitches((prevReadSwitches) => {
|
||||
const updatedReadSwitches = { ...prevReadSwitches, [key]: value };
|
||||
return updatedReadSwitches;
|
||||
});
|
||||
} else {
|
||||
setWriteSwitches((prevWriteSwitches) => {
|
||||
const updatedWriteSwitches = { ...prevWriteSwitches, [key]: value };
|
||||
return updatedWriteSwitches;
|
||||
});
|
||||
const handleReadSwitch = (key: string, value: boolean) => {
|
||||
setReadSwitches((prev) => ({ ...prev, [key]: value }));
|
||||
// A column the recipient cannot read cannot be written either
|
||||
if (!value) {
|
||||
setWriteSwitches((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const getReadPermissions = () => {
|
||||
const handleWriteSwitch = (key: string, value: boolean) => {
|
||||
setWriteSwitches((prev) => ({ ...prev, [key]: value }));
|
||||
// Granting write access requires read access
|
||||
if (value) {
|
||||
setReadSwitches((prev) => ({ ...prev, [key]: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedRead = useMemo(() => {
|
||||
if (readPermissions === 'full' || writePermissions === 'full') {
|
||||
return 'full';
|
||||
}
|
||||
const keys = allColumns.filter((column) => isReadOn(column.value)).map((column) => column.value);
|
||||
return keys.length ? keys.join(',') : '-';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [readPermissions, writePermissions, readSwitches, allColumns]);
|
||||
|
||||
return Object.entries(readSwitches)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => key)
|
||||
.join(',');
|
||||
};
|
||||
|
||||
const getWritePermissions = () => {
|
||||
const resolvedWrite = useMemo(() => {
|
||||
if (writePermissions === 'full') {
|
||||
return 'full';
|
||||
}
|
||||
const keys = allColumns.filter((column) => isWriteOn(column.value)).map((column) => column.value);
|
||||
return keys.length ? keys.join(',') : '-';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [writePermissions, writeSwitches, allColumns]);
|
||||
|
||||
return Object.entries(writeSwitches)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key]) => key)
|
||||
.join(',');
|
||||
};
|
||||
// Notify the parent of the resolved permissions. onChange is expected to be stable.
|
||||
useEffect(() => {
|
||||
onChange({ read: resolvedRead, write: resolvedWrite });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resolvedRead, resolvedWrite]);
|
||||
|
||||
const noReadAccess = resolvedRead === '-';
|
||||
|
||||
return (
|
||||
<Panel.Indent>
|
||||
<input name='read' hidden readOnly ref={readRef} value={getReadPermissions() || '-'} />
|
||||
<input name='write' hidden readOnly ref={writeRef} value={getWritePermissions() || '-'} />
|
||||
<div>
|
||||
<Panel.Field title='Access mode' description='Which parts of the data will the link give access to' />
|
||||
<Panel.Field title='Access mode' description='Which parts of the data the link gives access to' />
|
||||
<div>
|
||||
<RadioGroup
|
||||
value={writePermissions}
|
||||
onValueChange={handleWriteModeChange}
|
||||
orientation='horizontal'
|
||||
items={[
|
||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom write' },
|
||||
]}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={readPermissions}
|
||||
onValueChange={handleReadModeChange}
|
||||
@@ -132,8 +155,18 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
{ value: 'custom', label: 'Custom read' },
|
||||
]}
|
||||
/>
|
||||
<RadioGroup
|
||||
value={writePermissions}
|
||||
onValueChange={handleWriteModeChange}
|
||||
orientation='horizontal'
|
||||
items={[
|
||||
{ value: 'full', label: 'Full write (edit all existing and future columns)' },
|
||||
{ value: 'custom', label: 'Custom write' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{noReadAccess && <Panel.Error>Links must contain at least one readable column.</Panel.Error>}
|
||||
<div className={style.twoCols}>
|
||||
<div className={style.grid}>
|
||||
<Panel.Description>Ontime columns</Panel.Description>
|
||||
@@ -143,14 +176,14 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
<Fragment key={column.value}>
|
||||
<div>{column.label}</div>
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
checked={isReadOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
checked={isWriteOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
@@ -164,16 +197,16 @@ export default function CuesheetLinkOptions({ readRef, writeRef }: CuesheetLinkO
|
||||
<Panel.Description>Write</Panel.Description>
|
||||
{customFieldColumns.map((column) => (
|
||||
<Fragment key={column.value}>
|
||||
{column.label}
|
||||
<div>{column.label}</div>
|
||||
<Switch
|
||||
checked={Boolean(readSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'read', value)}
|
||||
checked={isReadOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleReadSwitch(column.value, value)}
|
||||
disabled={readPermissions === 'full' || writePermissions === 'full'}
|
||||
data-testid={`read-${column.value}`}
|
||||
/>
|
||||
<Switch
|
||||
checked={Boolean(writeSwitches[column.value])}
|
||||
onCheckedChange={(value: boolean) => handleSwitchChange(column.value, 'write', value)}
|
||||
checked={isWriteOn(column.value)}
|
||||
onCheckedChange={(value: boolean) => handleWriteSwitch(column.value, value)}
|
||||
disabled={writePermissions === 'full'}
|
||||
data-testid={`write-${column.value}`}
|
||||
/>
|
||||
|
||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langDe: TranslationObject = {
|
||||
'timeline.done': 'Beendet',
|
||||
'timeline.due': 'fällig',
|
||||
'timeline.followedby': 'Gefolgt von',
|
||||
'timeline.standby': 'Bereitschaft',
|
||||
'project.title': 'Titel',
|
||||
'project.description': 'Beschreibung',
|
||||
'project.info': 'Projektinfo',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langEs: TranslationObject = {
|
||||
'timeline.done': 'Terminado',
|
||||
'timeline.due': 'pendiente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'timeline.standby': 'En espera',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descripción',
|
||||
'project.info': 'Información del proyecto',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langFr: TranslationObject = {
|
||||
'timeline.done': 'Terminé',
|
||||
'timeline.due': 'dû',
|
||||
'timeline.followedby': 'Suivi de',
|
||||
'timeline.standby': 'En attente',
|
||||
'project.title': 'Titre',
|
||||
'project.description': 'Description',
|
||||
'project.info': 'Informations du projet',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langIt: TranslationObject = {
|
||||
'timeline.done': 'Terminato',
|
||||
'timeline.due': 'previsto',
|
||||
'timeline.followedby': 'Seguito da',
|
||||
'timeline.standby': 'In attesa',
|
||||
'project.title': 'Titolo',
|
||||
'project.description': 'Descrizione',
|
||||
'project.info': 'Informazioni sul progetto',
|
||||
|
||||
@@ -28,6 +28,7 @@ export const langPt: TranslationObject = {
|
||||
'timeline.done': 'Concluído',
|
||||
'timeline.due': 'Pendente',
|
||||
'timeline.followedby': 'Seguido por',
|
||||
'timeline.standby': 'Em espera',
|
||||
'project.title': 'Título',
|
||||
'project.description': 'Descrição',
|
||||
'project.info': 'Informações do projeto',
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-read-only',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: '-',
|
||||
@@ -41,6 +42,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-flag',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'full',
|
||||
write: 'flag',
|
||||
@@ -62,6 +64,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-default',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
};
|
||||
|
||||
const policy = getCuesheetColumnAccessPolicy(preset, AppMode.Edit);
|
||||
@@ -76,6 +79,7 @@ describe('getCuesheetPermissionsPolicy()', () => {
|
||||
alias: 'cuesheet-granular',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: false,
|
||||
options: {
|
||||
read: 'cue,title',
|
||||
write: 'title',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ContextProp,
|
||||
@@ -20,6 +20,7 @@ import { usePersistedRundownOptions } from '../../../features/rundown/rundown.op
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
@@ -62,7 +63,8 @@ export default function CuesheetTable({
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||
|
||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||
const optionsStore = useOptions();
|
||||
@@ -202,8 +204,9 @@ export default function CuesheetTable({
|
||||
listeners,
|
||||
rows,
|
||||
table,
|
||||
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
|
||||
}),
|
||||
[columnSizeVars, cursor, listeners, rows, table],
|
||||
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
@@ -273,10 +276,13 @@ interface CuesheetVirtuosoContext {
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
|
||||
return <EmptyTableBody text='No data in rundown' />;
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
||||
context,
|
||||
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
||||
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
||||
});
|
||||
|
||||
const CuesheetTableElement = memo(function CuesheetTableElement({
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@use '@/theme/viewerDefs' as *;
|
||||
|
||||
$timeline-height: 1rem;
|
||||
$group-band-height: 0.5rem;
|
||||
$group-band-gap: 0.25rem;
|
||||
$timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-override, $viewer-background-color));
|
||||
|
||||
.timelineContainer {
|
||||
@@ -17,7 +19,21 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
||||
position: relative;
|
||||
font-weight: 600;
|
||||
height: 100%;
|
||||
box-shadow: inset 0 1rem 0 0 var(--card-background-color-override, $viewer-card-bg-color);
|
||||
// reserve the top region (progress bar + gap + group band) with the card background
|
||||
box-shadow: inset 0 calc(#{$timeline-height} + #{$group-band-gap} + #{$group-band-height}) 0 0
|
||||
var(--card-background-color-override, $viewer-card-bg-color);
|
||||
}
|
||||
|
||||
.groupBand {
|
||||
height: $group-band-height;
|
||||
margin-top: $group-band-gap;
|
||||
width: 100%;
|
||||
background: var(--group-colour, transparent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.column {
|
||||
@@ -92,7 +108,6 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
||||
border-top: 2px solid var(--background-color-override, $viewer-background-color);
|
||||
box-shadow: 0 0.25rem 0 0 var(--color, $gray-300);
|
||||
|
||||
text-transform: capitalize;
|
||||
white-space: normal;
|
||||
|
||||
&[data-status='done'] {
|
||||
@@ -119,6 +134,7 @@ $timeline-color: color-mix(in srgb, transparent 60%, var(--background-color-over
|
||||
|
||||
.status {
|
||||
width: fit-content;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status.due {
|
||||
|
||||
@@ -55,10 +55,6 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
||||
return calculateTimelineLayout(playableEvents, scheduleStart, scheduleEnd, screenWidth, !fixedSize);
|
||||
}, [rundown, scheduleStart, scheduleEnd, screenWidth, fixedSize]);
|
||||
|
||||
if (totalDuration === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pre-calculate event statuses
|
||||
let currentStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
|
||||
const statusMap: Record<string, ProgressStatus> = {};
|
||||
@@ -105,6 +101,7 @@ function Timeline({ firstStart, rundown, selectedEventId, totalDuration }: Timel
|
||||
title={displayTitle}
|
||||
cue={event.cue}
|
||||
width={position.width}
|
||||
groupColour={event.groupColour}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface TimelineEntryProps {
|
||||
title: string;
|
||||
width: number;
|
||||
cue: string;
|
||||
groupColour?: string;
|
||||
ref?: RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
@@ -48,6 +49,7 @@ export function TimelineEntry({
|
||||
title,
|
||||
width,
|
||||
cue,
|
||||
groupColour,
|
||||
ref,
|
||||
}: TimelineEntryProps) {
|
||||
const formattedStartTime = formatTime(start, formatOptions);
|
||||
@@ -67,6 +69,7 @@ export function TimelineEntry({
|
||||
{
|
||||
'--color': colour,
|
||||
'--lighter': lighterColour ?? '',
|
||||
'--group-colour': groupColour ?? 'transparent',
|
||||
left: `${left}px`,
|
||||
width: `${width}px`,
|
||||
} as CSSProperties
|
||||
@@ -74,6 +77,7 @@ export function TimelineEntry({
|
||||
data-testid={cue}
|
||||
>
|
||||
{status === 'live' ? <ActiveBlock /> : <div data-status={status} className={style.timelineBlock} />}
|
||||
<div className={style.groupBand} />
|
||||
<div
|
||||
className={cx([style.content, width < 20 && style.hide, !hasLink && style.separeLeft])}
|
||||
data-status={status}
|
||||
@@ -153,6 +157,8 @@ function TimelineEntryStatus({
|
||||
statusText = getLocalizedString('timeline.live');
|
||||
} else if (statusText === 'pending') {
|
||||
statusText = getLocalizedString('timeline.due');
|
||||
} else if (statusText === 'done') {
|
||||
statusText = getLocalizedString('timeline.done');
|
||||
}
|
||||
|
||||
const isDue = status === 'future' && timeToStart <= 0;
|
||||
|
||||
@@ -38,6 +38,8 @@ export default function TimelinePageLoader() {
|
||||
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const { mainSource, timeformat } = useTimelineOptions();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
// holds copy of the rundown with only relevant events
|
||||
const { scopedRundown, firstStart, totalDuration } = useScopedRundown(events, selectedEventId);
|
||||
|
||||
@@ -50,6 +52,8 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
const progressOptions = useMemo(() => getTimelineOptions(defaultFormat, customFields), [defaultFormat, customFields]);
|
||||
|
||||
const hasContent = totalDuration > 0;
|
||||
|
||||
return (
|
||||
<div className='timeline' data-testid='timeline-view'>
|
||||
<ViewParamsEditor target={OntimeView.Timeline} viewOptions={progressOptions} />
|
||||
@@ -61,12 +65,16 @@ function TimelinePage({ events, customFields, projectData, settings }: TimelineD
|
||||
|
||||
<TimelineSections now={now} next={next} followedBy={followedBy} mainSource={mainSource} />
|
||||
|
||||
<Timeline
|
||||
firstStart={firstStart}
|
||||
rundown={scopedRundown}
|
||||
selectedEventId={selectedEventId}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
{hasContent ? (
|
||||
<Timeline
|
||||
firstStart={firstStart}
|
||||
rundown={scopedRundown}
|
||||
selectedEventId={selectedEventId}
|
||||
totalDuration={totalDuration}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPage text={getLocalizedString('common.no_data')} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function TimelineSections({ now, next, followedBy, mainSource }:
|
||||
const state = useExpectedStartData();
|
||||
|
||||
// gather card data
|
||||
const titleNow = getPropertyValue(now, mainSource ?? 'title') ?? '-';
|
||||
const titleNow = now ? (getPropertyValue(now, mainSource ?? 'title') ?? '-') : getLocalizedString('timeline.standby');
|
||||
const dueText = getLocalizedString('timeline.due').toUpperCase();
|
||||
const nextText = getPropertyValue(next, mainSource ?? 'title') ?? '-';
|
||||
const followedByText = getPropertyValue(followedBy, mainSource ?? 'title') ?? '-';
|
||||
|
||||
@@ -96,63 +96,77 @@ export function useScopedRundown(
|
||||
): ScopedRundownData {
|
||||
const { hidePast } = useTimelineOptions();
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (rundown.length === 0) {
|
||||
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
|
||||
}
|
||||
|
||||
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
|
||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||
let firstStart = null;
|
||||
let totalDuration = 0;
|
||||
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
|
||||
|
||||
for (let i = 0; i < rundown.length; i++) {
|
||||
const currentEntry = rundown[i];
|
||||
// we only deal with playableEvents
|
||||
if (isOntimeEvent(currentEntry) && isPlayableEvent(currentEntry)) {
|
||||
if (currentEntry.id === selectedEventId) {
|
||||
selectedIndex = i;
|
||||
}
|
||||
|
||||
// maybe filter past
|
||||
if (hidePast && i < selectedIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add to scopedRundown
|
||||
scopedRundown.push(currentEntry);
|
||||
|
||||
/**
|
||||
* Derive timers
|
||||
* This logic is partially from rundownCache.generate
|
||||
* With the addition of deriving the current day offset
|
||||
*/
|
||||
if (firstStart === null) {
|
||||
firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
|
||||
|
||||
if (timeFromPrevious === 0) {
|
||||
totalDuration += currentEntry.duration;
|
||||
} else if (timeFromPrevious > 0) {
|
||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
||||
} else if (timeFromPrevious < 0) {
|
||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||
}
|
||||
if (isNewLatest(currentEntry, lastEntry)) {
|
||||
lastEntry = currentEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
|
||||
}, [hidePast, rundown, selectedEventId]);
|
||||
const data = useMemo(
|
||||
() => computeScopedRundown(rundown, selectedEventId, hidePast),
|
||||
[hidePast, rundown, selectedEventId],
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure computation behind useScopedRundown: filters to playable events and derives timers
|
||||
*/
|
||||
export function computeScopedRundown(
|
||||
rundown: ExtendedEntry<OntimeEntry>[],
|
||||
selectedEventId: MaybeString,
|
||||
hidePast: boolean,
|
||||
): ScopedRundownData {
|
||||
if (rundown.length === 0) {
|
||||
return { scopedRundown: [], firstStart: 0, totalDuration: 0 };
|
||||
}
|
||||
|
||||
const scopedRundown: ExtendedEntry<PlayableEvent>[] = [];
|
||||
let selectedIndex = selectedEventId ? Infinity : -1;
|
||||
let firstStart: number | null = null;
|
||||
let totalDuration = 0;
|
||||
let lastEntry: ExtendedEntry<PlayableEvent> | null = null;
|
||||
|
||||
for (let i = 0; i < rundown.length; i++) {
|
||||
// we only deal with playableEvents
|
||||
const currentEntry = rundown[i];
|
||||
if (!isOntimeEvent(currentEntry) || !isPlayableEvent(currentEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentEntry.id === selectedEventId) {
|
||||
selectedIndex = i;
|
||||
}
|
||||
|
||||
// maybe filter past
|
||||
if (hidePast && i < selectedIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add to scopedRundown
|
||||
scopedRundown.push(currentEntry);
|
||||
|
||||
/**
|
||||
* Derive timers
|
||||
* This logic is partially from rundownCache.generate
|
||||
* With the addition of deriving the current day offset
|
||||
*/
|
||||
if (firstStart === null) {
|
||||
firstStart = currentEntry.timeStart;
|
||||
}
|
||||
|
||||
const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
|
||||
|
||||
if (timeFromPrevious === 0) {
|
||||
totalDuration += currentEntry.duration;
|
||||
} else if (timeFromPrevious > 0) {
|
||||
totalDuration += timeFromPrevious + currentEntry.duration;
|
||||
} else if (timeFromPrevious < 0) {
|
||||
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
|
||||
}
|
||||
if (isNewLatest(currentEntry, lastEntry)) {
|
||||
lastEntry = currentEntry;
|
||||
}
|
||||
}
|
||||
|
||||
return { scopedRundown, firstStart: firstStart ?? 0, totalDuration };
|
||||
}
|
||||
|
||||
type UpcomingEvents = {
|
||||
now: ExtendedEntry<OntimeEvent> | null;
|
||||
next: ExtendedEntry<OntimeEvent> | null;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.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.9.0",
|
||||
"version": "4.10.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,21 +2,22 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
"@modelcontextprotocol/sdk": "^1.15.0",
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "5.1.0",
|
||||
"express-static-gzip": "3.0.0",
|
||||
"express-validator": "7.2.1",
|
||||
"fast-equals": "^5.0.1",
|
||||
"express": "5.2.1",
|
||||
"express-static-gzip": "3.0.1",
|
||||
"express-validator": "7.3.2",
|
||||
"fast-equals": "^6.0.0",
|
||||
"google-auth-library": "^9.4.2",
|
||||
"lowdb": "^7.0.1",
|
||||
"multer": "2.1.0",
|
||||
"multer": "2.2.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
@@ -24,9 +25,9 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "^8.5.10",
|
||||
|
||||
@@ -110,14 +110,13 @@ type old_URLPreset = {
|
||||
/**
|
||||
* migrates a url presets from v3 to v4
|
||||
* - pathAndParams split into a target and search
|
||||
*
|
||||
*/
|
||||
export function migrateURLPresets(jsonData: object): URLPreset[] | undefined {
|
||||
if (is.objectWithKeys(jsonData, ['urlPresets']) && is.array(jsonData.urlPresets)) {
|
||||
const oldURLPresets = structuredClone(jsonData.urlPresets) as old_URLPreset;
|
||||
const newURLPreset: URLPreset[] = oldURLPresets.map(({ enabled, alias, pathAndParams }) => {
|
||||
const [target, search] = pathAndParams.split('?');
|
||||
return { enabled, alias, target, search, options: {} } as URLPreset;
|
||||
return { enabled, alias, target, search, displayInNav: false, options: {} } as URLPreset;
|
||||
});
|
||||
return newURLPreset;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ describe('v3 to v4', () => {
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
displayInNav: false,
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
@@ -216,6 +217,7 @@ describe('v3 to v4', () => {
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
displayInNav: false,
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,13 +2,11 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
duplicateRundown,
|
||||
getIntegerAndFraction,
|
||||
hasChanges,
|
||||
makeDeepClone,
|
||||
@@ -223,25 +221,6 @@ describe('calculateDayOffset()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicateRundown', () => {
|
||||
it('duplicates a given rundown', () => {
|
||||
const demoRundown = demoDb.rundowns['default'];
|
||||
const title = 'Duplicated Rundown';
|
||||
const duplicatedRundown = duplicateRundown(demoRundown, title);
|
||||
|
||||
expect(duplicatedRundown).toMatchObject({
|
||||
title: title,
|
||||
entries: expect.any(Object),
|
||||
order: expect.any(Array),
|
||||
flatOrder: expect.any(Array),
|
||||
});
|
||||
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
|
||||
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
|
||||
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
|
||||
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeDeepClone()', () => {
|
||||
it('deep clones a group along with its nested entries', () => {
|
||||
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
|
||||
|
||||
@@ -15,16 +15,18 @@ import {
|
||||
createNewRundown,
|
||||
deleteAllEntries,
|
||||
deleteEntries,
|
||||
deleteRundown,
|
||||
duplicateExistingRundown,
|
||||
editEntry,
|
||||
groupEntries,
|
||||
initRundown,
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
renumberEntries,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
} from './rundown.service.js';
|
||||
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
clonePostValidator,
|
||||
entryBatchPutValidator,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
entryReorderValidator,
|
||||
entrySwapValidator,
|
||||
rundownArrayOfIds,
|
||||
rundownPatchValidator,
|
||||
rundownPostValidator,
|
||||
} from './rundown.validation.js';
|
||||
|
||||
@@ -44,7 +47,7 @@ export const router: Router = express.Router();
|
||||
/**
|
||||
* Returns all rundowns in the project
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
});
|
||||
@@ -52,7 +55,7 @@ router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
/**
|
||||
* Returns the current rundown
|
||||
*/
|
||||
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||
router.get('/current', (_req: Request, res: Response<Rundown>) => {
|
||||
const rundown = getCurrentRundown();
|
||||
res.json(rundown);
|
||||
});
|
||||
@@ -60,7 +63,7 @@ router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||
/**
|
||||
* Returns a given rundown in its normalised client shape
|
||||
*/
|
||||
router.get('/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
router.get('/:id', paramsWithId, (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = getProcessedRundown(req.params.id);
|
||||
res.json(rundown);
|
||||
@@ -104,13 +107,7 @@ router.post(
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
|
||||
const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
|
||||
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const projectRundowns = await duplicateExistingRundown(req.params.id);
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -123,54 +120,26 @@ router.post(
|
||||
* Patches the data of an existing rundown
|
||||
* Currently only the title can be changed
|
||||
*/
|
||||
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
|
||||
if (!req.body.title) throw new Error('No title provided');
|
||||
|
||||
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
|
||||
|
||||
/**
|
||||
* If loaded we re-init the rundown
|
||||
* This is likely over-kill but the simplest way to ensure state consistency
|
||||
*/
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
await initRundown(rundown, customField);
|
||||
router.patch(
|
||||
'/:id',
|
||||
rundownPatchValidator,
|
||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const projectRundowns = await renameRundown(req.params.id, req.body.title);
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a rundown if not loaded
|
||||
*/
|
||||
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
res.status(400).send({ message: 'Cannot delete loaded rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
const projectRundowns = dataProvider.getProjectRundowns();
|
||||
|
||||
if (Object.keys(projectRundowns).length <= 1) {
|
||||
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
|
||||
res.status(400).send({ message: 'Cannot delete the last rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
await dataProvider.deleteRundown(req.params.id);
|
||||
const newProjectRundowns = getDataProvider().getProjectRundowns();
|
||||
const newProjectRundowns = await deleteRundown(req.params.id);
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
@@ -692,3 +692,76 @@ export async function createNewRundown(title: string) {
|
||||
|
||||
return projectRundowns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an existing rundown
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function renameRundown(id: string, title: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
await dataProvider.setRundown(id, { ...rundown, title });
|
||||
|
||||
/**
|
||||
* If we are modifying the loaded rundown we re-init it
|
||||
* This is likely over-kill but the simplest way to ensure state consistency
|
||||
*/
|
||||
if (isCurrentRundown(id)) {
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
// init rundown does its own refetch
|
||||
await initRundown(rundown, customField);
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
}
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing rundown without making it the loaded one
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function duplicateExistingRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
const duplicatedRundown: Rundown = structuredClone(rundown);
|
||||
duplicatedRundown.id = generateId();
|
||||
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
||||
duplicatedRundown.revision = 0;
|
||||
|
||||
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a rundown
|
||||
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
||||
*/
|
||||
export async function deleteRundown(id: string) {
|
||||
if (isCurrentRundown(id)) {
|
||||
throw new Error('Cannot delete loaded rundown');
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
|
||||
throw new Error('Cannot delete the last rundown');
|
||||
}
|
||||
|
||||
const projectRundowns = await dataProvider.deleteRundown(id);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
return projectRundowns;
|
||||
}
|
||||
|
||||
@@ -34,43 +34,34 @@ import {
|
||||
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
|
||||
type CompleteEntry<T> =
|
||||
T extends Partial<OntimeEvent>
|
||||
? OntimeEvent
|
||||
: T extends Partial<OntimeDelay>
|
||||
? OntimeDelay
|
||||
: T extends Partial<OntimeGroup>
|
||||
? OntimeGroup
|
||||
: T extends Partial<OntimeMilestone>
|
||||
? OntimeMilestone
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Generates a fully formed RundownEntry of the patch type
|
||||
*/
|
||||
export function generateEvent<
|
||||
T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
|
||||
>(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry<T> {
|
||||
export function generateEvent(
|
||||
rundown: Rundown,
|
||||
eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
|
||||
afterId: EntryId | null,
|
||||
parent?: EntryId,
|
||||
): OntimeEntry {
|
||||
if (isOntimeEvent(eventData)) {
|
||||
return createEvent(
|
||||
eventData,
|
||||
getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent),
|
||||
) as CompleteEntry<T>;
|
||||
const event = createEvent(eventData, getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent));
|
||||
if (!event) throw new Error('Invalid event type');
|
||||
return event;
|
||||
}
|
||||
|
||||
const id = eventData.id || getUniqueId(rundown);
|
||||
|
||||
if (isOntimeDelay(eventData)) {
|
||||
return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry<T>;
|
||||
return createDelay({ duration: eventData.duration ?? 0, id });
|
||||
}
|
||||
|
||||
// TODO(v4): allow user to provide a larger patch of the group entry
|
||||
if (isOntimeGroup(eventData)) {
|
||||
return createGroup({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
|
||||
return createGroup({ id, title: eventData.title ?? '' });
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(eventData)) {
|
||||
return createMilestone({ ...eventData, id }) as CompleteEntry<T>;
|
||||
return createMilestone({ ...eventData, id });
|
||||
}
|
||||
|
||||
throw new Error('Invalid event type');
|
||||
@@ -460,20 +451,6 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing rundown ensuring all IDs are unique
|
||||
*/
|
||||
export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
|
||||
const newRundownId = generateId();
|
||||
|
||||
const newRundown = structuredClone(rundown);
|
||||
newRundown.id = newRundownId;
|
||||
newRundown.title = newTitle;
|
||||
newRundown.revision = 0;
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
export type IncrementNumber = {
|
||||
integer: number;
|
||||
faction: number;
|
||||
|
||||
@@ -5,6 +5,11 @@ import { requestValidationFunction } from '../validation-utils/validationFunctio
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
|
||||
export const rundownPatchValidator = [
|
||||
param('id').isString().trim().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().withMessage('No title provided'),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
@@ -12,7 +12,9 @@ describe('parseUrlPresets()', () => {
|
||||
|
||||
it('parses data, skipping invalid results', () => {
|
||||
const errorEmitter = vi.fn();
|
||||
const urlPresets = [{ enabled: true, alias: 'alias', target: 'timer', search: 'ss' }] as URLPreset[];
|
||||
const urlPresets = [
|
||||
{ enabled: true, alias: 'alias', target: 'timer', search: 'ss', displayInNav: false },
|
||||
] as URLPreset[];
|
||||
const result = parseUrlPresets({ urlPresets }, errorEmitter);
|
||||
expect(result.length).toEqual(1);
|
||||
expect(result.at(0)).toMatchObject({
|
||||
@@ -36,6 +38,7 @@ describe('parseUrlPresets()', () => {
|
||||
alias: 'testalias',
|
||||
target: 'timer',
|
||||
search: 'testpathAndParams',
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
} as unknown as DatabaseModel;
|
||||
|
||||
@@ -26,6 +26,7 @@ export function parseUrlPresets(data: Partial<DatabaseModel>, emitError?: ErrorE
|
||||
alias: preset.alias,
|
||||
target: preset.target,
|
||||
search: preset.search ?? '',
|
||||
displayInNav: preset.displayInNav ?? false,
|
||||
options: preset?.options,
|
||||
};
|
||||
newPresets.push(newPreset);
|
||||
|
||||
@@ -21,6 +21,7 @@ router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options,
|
||||
};
|
||||
|
||||
@@ -55,6 +56,7 @@ router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<U
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export const validateNewPreset = [
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
body('displayInNav').isBoolean(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
@@ -27,6 +28,7 @@ export const validateUpdatePreset = [
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
body('displayInNav').isBoolean(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { SupportedEntry, type EventPostPayload, type OntimeEntry, type PatchWithId, type Rundown } from 'ontime-types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const addEntryMock = vi.hoisted(() => vi.fn());
|
||||
const editEntryMock = vi.hoisted(() => vi.fn());
|
||||
const groupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const ungroupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const getCurrentRundownMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getCurrentRundown: getCurrentRundownMock,
|
||||
getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
getDataProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
addEntry: addEntryMock,
|
||||
batchEditEntries: vi.fn(),
|
||||
deleteEntries: vi.fn(),
|
||||
editEntry: editEntryMock,
|
||||
groupEntries: groupEntriesMock,
|
||||
reorderEntry: vi.fn(),
|
||||
ungroupEntries: ungroupEntriesMock,
|
||||
}));
|
||||
|
||||
const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js');
|
||||
|
||||
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
|
||||
return {
|
||||
id: 'loaded-rundown',
|
||||
title: 'Loaded',
|
||||
order,
|
||||
flatOrder: order,
|
||||
entries,
|
||||
revision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mcp.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCurrentRundownMock.mockReturnValue(makeRundown({}));
|
||||
|
||||
let id = 0;
|
||||
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
|
||||
id += 1;
|
||||
const entryId = `entry-${id}`;
|
||||
|
||||
if (payload.type === SupportedEntry.Group) {
|
||||
return {
|
||||
id: entryId,
|
||||
type: SupportedEntry.Group,
|
||||
title: payload.title ?? '',
|
||||
note: '',
|
||||
colour: '',
|
||||
custom: {},
|
||||
targetDuration: null,
|
||||
entries: [],
|
||||
revision: 0,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: entryId,
|
||||
type: payload.type ?? SupportedEntry.Event,
|
||||
title: payload.title ?? '',
|
||||
parent: 'parent' in payload ? payload.parent : null,
|
||||
} as OntimeEntry;
|
||||
});
|
||||
|
||||
editEntryMock.mockImplementation(async (_rundownId: string, patch: PatchWithId) => ({
|
||||
id: patch.id,
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Session block',
|
||||
note: patch.note ?? '',
|
||||
colour: patch.colour ?? '',
|
||||
custom: patch.custom ?? {},
|
||||
targetDuration: patch.targetDuration ?? null,
|
||||
entries: [],
|
||||
revision: 1,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates grouped batch entries with group metadata and child parent references', async () => {
|
||||
const result = await batchCreateEntriesForMcp({
|
||||
entries: [
|
||||
{
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Session block',
|
||||
note: 'Main room',
|
||||
colour: '#123456',
|
||||
targetDuration: 3_600_000,
|
||||
children: [
|
||||
{ type: SupportedEntry.Event, title: 'Talk', timeStart: 36_000_000, duration: 1_800_000 },
|
||||
{ type: SupportedEntry.Milestone, title: 'Reset stage' },
|
||||
],
|
||||
},
|
||||
{ type: SupportedEntry.Event, title: 'After block', timeStart: 39_600_000, duration: 900_000 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Group, title: 'Session block' }),
|
||||
);
|
||||
expect(editEntryMock).toHaveBeenCalledWith(
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({
|
||||
id: 'entry-1',
|
||||
note: 'Main room',
|
||||
colour: '#123456',
|
||||
targetDuration: 3_600_000,
|
||||
}),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'Talk', parent: 'entry-1' }),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({
|
||||
type: SupportedEntry.Milestone,
|
||||
title: 'Reset stage',
|
||||
parent: 'entry-1',
|
||||
after: 'entry-2',
|
||||
}),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'After block', after: 'entry-1' }),
|
||||
);
|
||||
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
||||
});
|
||||
|
||||
it('rejects nested groups before creating entries', async () => {
|
||||
await expect(
|
||||
batchCreateEntriesForMcp({
|
||||
entries: [
|
||||
{
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Outer',
|
||||
children: [{ type: SupportedEntry.Group, title: 'Inner' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Cannot create a group inside another group.');
|
||||
|
||||
expect(addEntryMock).not.toHaveBeenCalled();
|
||||
expect(editEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('groups existing top-level entries and applies group metadata', async () => {
|
||||
const sourceRundown = makeRundown(
|
||||
{
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry,
|
||||
'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: null } as OntimeEntry,
|
||||
},
|
||||
['entry-1', 'entry-2'],
|
||||
);
|
||||
const groupedRundown = makeRundown(
|
||||
{
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '',
|
||||
custom: {},
|
||||
targetDuration: null,
|
||||
entries: ['entry-1', 'entry-2'],
|
||||
revision: 0,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
},
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: 'group' } as OntimeEntry,
|
||||
},
|
||||
['group'],
|
||||
);
|
||||
|
||||
getCurrentRundownMock.mockReturnValue(sourceRundown);
|
||||
groupEntriesMock.mockResolvedValue(groupedRundown);
|
||||
|
||||
const result = await groupEntriesForMcp({
|
||||
ids: ['entry-1', 'entry-2'],
|
||||
title: 'Block',
|
||||
colour: '#abcdef',
|
||||
targetDuration: 1_200_000,
|
||||
});
|
||||
|
||||
expect(groupEntriesMock).toHaveBeenCalledWith('loaded-rundown', ['entry-1', 'entry-2']);
|
||||
expect(editEntryMock).toHaveBeenCalledWith(
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ id: 'group', colour: '#abcdef', targetDuration: 1_200_000 }),
|
||||
);
|
||||
expect(result.entry.id).toBe('group');
|
||||
expect(result.order).toEqual(['group']);
|
||||
});
|
||||
|
||||
it('rejects grouping nested entries before mutating', async () => {
|
||||
getCurrentRundownMock.mockReturnValue(
|
||||
makeRundown({
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry,
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(groupEntriesForMcp({ ids: ['entry-1'] })).rejects.toThrow(
|
||||
'Cannot group nested entry entry-1. Move it out of its group first.',
|
||||
);
|
||||
|
||||
expect(groupEntriesMock).not.toHaveBeenCalled();
|
||||
expect(editEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ungroups an existing group entry', async () => {
|
||||
getCurrentRundownMock.mockReturnValue(
|
||||
makeRundown({
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry,
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
}),
|
||||
);
|
||||
ungroupEntriesMock.mockResolvedValue(
|
||||
makeRundown(
|
||||
{
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry,
|
||||
},
|
||||
['entry-1'],
|
||||
),
|
||||
);
|
||||
|
||||
const result = await ungroupEntryForMcp({ id: 'group' });
|
||||
|
||||
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
|
||||
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { GetPromptResult, ListPromptsResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
|
||||
{
|
||||
name: 'create_rundown_from_agenda',
|
||||
description: 'Convert a plain-text agenda into an Ontime rundown using ontime_batch_create_entries',
|
||||
arguments: [{ name: 'agenda', description: 'Plain-text agenda to convert', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'bulk_edit_rundown',
|
||||
description: 'Apply a bulk change across the rundown (recolour, reschedule, skip events, etc.)',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'What to change, e.g. "colour all keynotes blue"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'validate_rundown',
|
||||
description: 'Check the current rundown for common issues: missing cues, overlaps, gaps, zero-duration events',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'restructure_rundown',
|
||||
description: 'Reorder events in the rundown according to an instruction',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'How to restructure, e.g. "move all breaks to after keynotes"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'manage_custom_fields',
|
||||
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function userPrompt(description: string, text: string): GetPromptResult {
|
||||
return { description, messages: [{ role: 'user', content: { type: 'text', text } }] };
|
||||
}
|
||||
|
||||
export function handleGetPrompt(name: string, args: Record<string, string>): GetPromptResult {
|
||||
if (name === 'create_rundown_from_agenda') {
|
||||
return userPrompt(
|
||||
'Build an Ontime rundown from a plain-text agenda',
|
||||
`Convert the following agenda into an Ontime rundown.
|
||||
Read the ontime://schema resource if you need a data model reference.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
||||
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
||||
|
||||
Entry type guidance:
|
||||
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
||||
- Use \`milestone\` for non-timed markers that don't advance playback (e.g. "Doors open", "Broadcast start").
|
||||
- Use \`delay\` only when the user explicitly wants to model schedule drift that shifts all following events.
|
||||
- Use \`group\` to collect related events into a named block. In ontime_batch_create_entries, put grouped items in the group's \`children\` array. A group may include \`title\`, \`colour\`, \`note\`, \`custom\`, and \`targetDuration\`; groups cannot be nested.
|
||||
|
||||
Event timing:
|
||||
- Provide a title plus enough timing data for Ontime to infer a timing strategy.
|
||||
- \`timeStart\` + \`duration\`: keeps duration fixed and calculates \`timeEnd\`.
|
||||
- \`timeStart\` + \`timeEnd\`: keeps end time fixed and calculates \`duration\`.
|
||||
- \`timeEnd\` + \`duration\`: calculates \`timeStart\`.
|
||||
- Avoid sending \`timeStart\`, \`timeEnd\`, and \`duration\` together unless you intentionally want Ontime to prioritise duration and recalculate \`timeEnd\`.
|
||||
|
||||
Timer type (timerType):
|
||||
- \`count-down\` (default): counts down from duration. Use for most timed sessions.
|
||||
- \`count-up\`: counts elapsed time. Use for open-ended items like Q&A or audience discussion.
|
||||
- \`clock\`: shows wall-clock time. Use for broadcast-start or house-open markers.
|
||||
- \`none\`: no timer shown. Use for purely informational or non-timed items.
|
||||
|
||||
Count to end (countToEnd):
|
||||
- This is an advanced countdown behaviour, not a timer type.
|
||||
- When \`countToEnd: true\`, the timer counts to the scheduled \`timeEnd\` instead of counting down the event duration.
|
||||
- This can surprise operators if an event starts late or the schedule shifts, because the displayed time may be shorter or longer than the nominal duration.
|
||||
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
|
||||
|
||||
End action (endAction):
|
||||
- Controls what happens when the event's timer reaches zero and can have surprising effect. Use only if the user explicitly asks for it.
|
||||
- \`none\` (default): stops at end; operator must manually start the next event.
|
||||
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
|
||||
- \`play-next\`: automatically starts the next event. Use for seamless back-to-back segments with no gap.
|
||||
|
||||
Linking (linkStart):
|
||||
- \`linkStart\` controls schedule-change propagation through the rundown.
|
||||
- When an event is linked, it inherits the end time of the previous playable event as its start time.
|
||||
- The event's \`timeStrategy\` decides how it adapts to the inherited start: lock duration updates the end time; lock end updates the duration.
|
||||
- Ideal for segments within a block where only the anchor start time and individual durations are managed directly.
|
||||
|
||||
Flags (flag):
|
||||
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
|
||||
- The operator view shows a countdown to the next flagged event — use sparingly for maximum impact.
|
||||
|
||||
Colours:
|
||||
- Ask the user what colour convention they use before applying any colours.
|
||||
- Common pattern: one colour per event type (keynotes, panels, breaks, meals).
|
||||
- Colours are hex strings: \`#RRGGBB\`.
|
||||
|
||||
Custom fields (custom):
|
||||
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
|
||||
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — only use keys that exist in the project.
|
||||
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
|
||||
Agenda:
|
||||
${args.agenda}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'bulk_edit_rundown') {
|
||||
return userPrompt(
|
||||
'Apply a bulk change across the rundown',
|
||||
`Apply the following bulk edit to the current Ontime rundown: "${args.instruction}"
|
||||
|
||||
Strategy:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current events, their IDs, and field values.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Determine which event IDs are affected by the instruction.
|
||||
5. If the user asks to group existing top-level entries: call ontime_group_entries once with { ids, title/note/colour/custom/targetDuration as needed, rundownId }.
|
||||
6. If the user asks to dissolve a group: call ontime_ungroup_entry with the group id and rundownId.
|
||||
7. If every affected entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data, rundownId }.
|
||||
8. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each.
|
||||
|
||||
Time shift mechanics:
|
||||
- All time fields are milliseconds from midnight; compute arithmetic before calling the tools.
|
||||
- timeEnd - timeStart = duration. When shifting times, decide whether to keep duration fixed (timeEnd moves with timeStart) or keep timeEnd fixed (duration shrinks). Provide only the fields you intend to change — the server infers the strategy from which fields are present.
|
||||
- For "shift everything N minutes later": update timeStart and timeEnd per event (or just timeStart on the anchor event of a linkStart chain). Do not use ontime_batch_update_entries for this unless every target event should receive the exact same timeStart/timeEnd values.
|
||||
|
||||
Automation risks:
|
||||
- Setting \`endAction: 'play-next'\` on multiple events creates an automatic playback chain that removes operator control between those events. Confirm with the user before applying.
|
||||
- Bulk-setting \`skip: true\` will hide events from playback. Confirm before applying to many events.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'validate_rundown') {
|
||||
return userPrompt(
|
||||
'Check the current rundown for common issues',
|
||||
`Validate the currently loaded Ontime rundown and report issues.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_get_rundown to read all events and their fields.
|
||||
2. Call ontime_get_rundown_metadata for totals (totalDuration, totalDelay, totalDays, firstStart, lastEnd, flags).
|
||||
|
||||
Check and report:
|
||||
|
||||
Schedule integrity:
|
||||
- Events with missing \`cue\` or \`title\`: these are usually worth checking, but not necessarily errors
|
||||
- Events with \`duration\` of 0 or negative
|
||||
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
|
||||
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
|
||||
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
|
||||
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
|
||||
|
||||
Timing and linking:
|
||||
- \`metadata.totalDays > 0\`: show spans midnight — confirm this is intentional
|
||||
- \`metadata.totalDelay !== 0\`: active delay entries are shifting the schedule by this many ms; report the net shift
|
||||
- Events with \`linkStart: true\` that are first in the rundown (no predecessor to link to)
|
||||
- Events with \`countToEnd: true\`: confirm the operator expects countdowns to target scheduled end time instead of event duration, especially if starts can drift.
|
||||
|
||||
Automation:
|
||||
- Events with \`endAction: 'play-next'\`: these form automatic playback chains. List each chain so the user can confirm the automation is intentional.
|
||||
- Events with \`flag: true\`: these are the operator's critical markers. List them so the user can verify they are correct and complete.
|
||||
|
||||
Skipped events:
|
||||
- Events with \`skip: true\` — confirm with the user these are intentional
|
||||
|
||||
Totals:
|
||||
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
|
||||
|
||||
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'restructure_rundown') {
|
||||
return userPrompt(
|
||||
'Reorder events in the rundown',
|
||||
`Restructure the current Ontime rundown: "${args.instruction}"
|
||||
|
||||
Steps:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current order, groups, and event fields.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP reorders the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Note which events are inside groups (check each group's \`entries\` array vs the top-level \`order\` array).
|
||||
5. If the instruction groups existing top-level entries into a new group, call ontime_group_entries with the selected IDs and any group metadata.
|
||||
6. If the instruction dissolves a group, call ontime_ungroup_entry with that group ID.
|
||||
7. Otherwise compute the target arrangement as a sequence of moves.
|
||||
8. For each event that needs to move, call ontime_reorder_entry:
|
||||
- \`order: 'before'\` or \`'after'\` — places the event as a sibling next to destinationId
|
||||
- \`order: 'insert'\` — places the event inside a group (destinationId must be the group's ID)
|
||||
9. Call ontime_get_rundown again to confirm the new order.
|
||||
|
||||
Group awareness:
|
||||
- Events inside a group appear in the group's \`entries\` array, not in the top-level \`order\`.
|
||||
- To create a group from existing top-level entries, prefer ontime_group_entries over creating an empty group and moving items one by one.
|
||||
- To dissolve a group, use ontime_ungroup_entry.
|
||||
- To move an event out of a group, reorder it before/after a top-level entry.
|
||||
- To move an event into a group, use \`order: 'insert'\` with the group as destinationId.
|
||||
- A group's \`targetDuration\` is a planning hint only — moving events in or out does not break anything.
|
||||
|
||||
Efficiency tip: plan moves in the direction of the target position to minimise reorder calls. Avoid moving the same event twice — compute the full target sequence before issuing any calls.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'manage_custom_fields') {
|
||||
return userPrompt(
|
||||
'List, create, rename, recolour, or delete custom field definitions',
|
||||
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
|
||||
|
||||
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras".
|
||||
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
|
||||
|
||||
Creating a field:
|
||||
- Call ontime_create_custom_field with { label, type, colour }.
|
||||
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating.
|
||||
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
|
||||
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified.
|
||||
- Returns: { key, customFields } — use the returned key when setting values on entries.
|
||||
|
||||
Renaming or recolouring a field:
|
||||
- Call ontime_update_custom_field with { key, label?, colour? }.
|
||||
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
|
||||
- The field type cannot be changed.
|
||||
|
||||
Deleting a field:
|
||||
- Call ontime_delete_custom_field with { key }.
|
||||
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
|
||||
|
||||
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown prompt: ${name}`);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
|
||||
|
||||
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
{
|
||||
uri: 'ontime://schema',
|
||||
name: 'ontime-schema',
|
||||
title: 'Ontime data model reference',
|
||||
description:
|
||||
'Markdown reference for rundown structure, event fields, time format, and entry types. Read once per session to ground tool calls in the correct data model.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://rundown/current',
|
||||
name: 'current-rundown',
|
||||
title: 'Currently loaded rundown',
|
||||
description:
|
||||
'The rundown currently active in Ontime, with its full entries map and order. Re-read after any mutating call to see updated state.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://rundowns',
|
||||
name: 'project-rundowns',
|
||||
title: 'All rundowns in the project',
|
||||
description: 'List of every rundown stored in the current project file, plus the ID of the one currently loaded.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://project/info',
|
||||
name: 'project-info',
|
||||
title: 'Project metadata',
|
||||
description: 'Project title, description, URL, info, logo, and custom header fields.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://project/custom-fields',
|
||||
name: 'project-custom-fields',
|
||||
title: 'Custom field definitions',
|
||||
description:
|
||||
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://docs',
|
||||
name: 'ontime-docs',
|
||||
title: 'Ontime documentation index',
|
||||
description:
|
||||
'Curated index of Ontime documentation topics with direct links to https://docs.getontime.no. Read this when you need to understand a concept in more depth or want to point the user to official documentation.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
|
||||
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
|
||||
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
|
||||
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
|
||||
'ontime://rundown/current': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getCurrentRundown()),
|
||||
},
|
||||
'ontime://rundowns': {
|
||||
mimeType: 'application/json',
|
||||
read: () => {
|
||||
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
|
||||
const loaded = getCurrentRundown().id;
|
||||
return JSON.stringify({ loaded, rundowns });
|
||||
},
|
||||
},
|
||||
'ontime://project/info': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getProjectData()),
|
||||
},
|
||||
'ontime://project/custom-fields': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getProjectCustomFields()),
|
||||
},
|
||||
};
|
||||
|
||||
export function handleReadResource(uri: string): ReadResourceResult {
|
||||
const reader = RESOURCE_READERS[uri];
|
||||
if (!reader) throw new Error(`Unknown resource URI: ${uri}`);
|
||||
return { contents: [{ uri, mimeType: reader.mimeType, text: reader.read() }] };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import express from 'express';
|
||||
import type { Request, Response, Router } from 'express';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { createMcpServer } from './mcp.server.js';
|
||||
|
||||
export const mcpRouter: Router = express.Router();
|
||||
|
||||
mcpRouter.post('/', async (req, res) => {
|
||||
// A new Server instance is created per request — required for stateless mode where
|
||||
// each POST is independent and concurrent requests must not share transport state.
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // stateless: no session tracking
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
const server = createMcpServer();
|
||||
|
||||
let cleanedUp = false;
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
transport.close();
|
||||
await server.close?.();
|
||||
};
|
||||
|
||||
res.on('close', () => void cleanup());
|
||||
res.on('finish', () => void cleanup());
|
||||
|
||||
try {
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(LogOrigin.Server, `MCP request failed: ${message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
|
||||
}
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
// Stateless mode: GET (SSE) and DELETE (session teardown) are not applicable.
|
||||
// All MCP interactions happen via POST in a single request/response cycle.
|
||||
const methodNotAllowed = (_req: Request, res: Response) =>
|
||||
void res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null });
|
||||
|
||||
mcpRouter.get('/', methodNotAllowed);
|
||||
mcpRouter.delete('/', methodNotAllowed);
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Agent-facing Ontime MCP documentation.
|
||||
*
|
||||
* Tool field schemas (EVENT_TIMER_FIELDS, EVENT_WRITABLE_FIELDS) and the schema resource
|
||||
* (ONTIME_SCHEMA_MARKDOWN) are maintained here so tool descriptions, prompt guidance,
|
||||
* and the agent-readable reference document use the same wording.
|
||||
*
|
||||
* Canonical type definitions live in packages/types/src/definitions/core/OntimeEntry.ts.
|
||||
* Keep this file concise and update it when MCP-exposed fields change.
|
||||
*/
|
||||
|
||||
// ---- Shared event field JSON schemas ----
|
||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
||||
|
||||
export const EVENT_TIMER_FIELDS = {
|
||||
timerType: {
|
||||
type: 'string',
|
||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
||||
},
|
||||
endAction: {
|
||||
type: 'string',
|
||||
enum: ['none', 'load-next', 'play-next'],
|
||||
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
|
||||
},
|
||||
linkStart: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
||||
},
|
||||
countToEnd: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
||||
},
|
||||
timeStrategy: {
|
||||
type: 'string',
|
||||
enum: ['lock-duration', 'lock-end'],
|
||||
description:
|
||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||
},
|
||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
||||
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
||||
} as const;
|
||||
|
||||
export const EVENT_WRITABLE_FIELDS = {
|
||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
||||
title: { type: 'string', description: 'Event title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||
colour: {
|
||||
type: 'string',
|
||||
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
|
||||
},
|
||||
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||
flag: {
|
||||
type: 'boolean',
|
||||
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
|
||||
},
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description:
|
||||
'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.',
|
||||
},
|
||||
...EVENT_TIMER_FIELDS,
|
||||
} as const;
|
||||
|
||||
export const RUNDOWN_TARGET_FIELD = {
|
||||
rundownId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---- Agent-readable schema document ----
|
||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
||||
// before issuing tool calls.
|
||||
|
||||
export const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model
|
||||
|
||||
A concise reference for how Ontime structures rundowns, events, and related data.
|
||||
|
||||
## Rundown
|
||||
|
||||
A rundown is an ordered list of entries rendered as a show schedule. A project can contain multiple rundowns; one is "loaded" at a time.
|
||||
|
||||
\`\`\`
|
||||
Rundown {
|
||||
id: string
|
||||
title: string
|
||||
order: EntryId[] // top-level entry order
|
||||
flatOrder: EntryId[] // includes entries nested in groups
|
||||
entries: { [id: EntryId]: OntimeEntry }
|
||||
revision: number
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Entries
|
||||
|
||||
There are four entry types discriminated by \`type\`:
|
||||
|
||||
### \`event\` — OntimeEvent (a timed show item)
|
||||
\`\`\`
|
||||
{
|
||||
type: 'event'
|
||||
id: EntryId
|
||||
cue: string // human-facing cue label
|
||||
title: string
|
||||
note: string
|
||||
colour: string // hex, e.g. "#4A90D9"
|
||||
timeStart: number // ms from midnight (09:00 = 32400000)
|
||||
timeEnd: number // ms from midnight
|
||||
duration: number // ms (= timeEnd - timeStart)
|
||||
delay: number // delay accumulated from rundown delay entries
|
||||
dayOffset: number // runtime calculated day offset from the rundown start schedule, increments when the rundown crosses midnight
|
||||
gap: number // schedule gap between sequential playable events; negative gap means overlap
|
||||
timerType: 'count-down' | 'count-up' | 'clock' | 'none'
|
||||
endAction: 'none' | 'load-next' | 'play-next'
|
||||
linkStart: boolean // chain start to previous event's end
|
||||
countToEnd: boolean // advanced mode: counts to scheduled timeEnd instead of duration; confirm before enabling
|
||||
skip: boolean // event is skipped during playback
|
||||
flag: boolean // critical operational marker, highlighted to operators
|
||||
timeStrategy: 'lock-duration' | 'lock-end'
|
||||
timeWarning: number // ms before end to trigger 'warning' state
|
||||
timeDanger: number // ms before end to trigger 'danger' state
|
||||
custom: { [key: string]: string } // custom field values
|
||||
triggers: Trigger[] // automation trigger references
|
||||
parent: EntryId | null // parent group, when nested
|
||||
revision: number // entry revision
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### \`delay\` — OntimeDelay (schedule shift applied to following events)
|
||||
\`\`\`
|
||||
{ type: 'delay', id, duration: number, parent: EntryId | null }
|
||||
\`\`\`
|
||||
|
||||
### \`group\` — OntimeGroup (nested container of entries)
|
||||
\`\`\`
|
||||
{
|
||||
type: 'group'
|
||||
id: EntryId
|
||||
title: string
|
||||
colour: string
|
||||
note: string
|
||||
entries: EntryId[]
|
||||
targetDuration: number | null
|
||||
custom: { [key: string]: string }
|
||||
timeStart: number | null // calculated from nested entries (runtime)
|
||||
timeEnd: number | null // calculated from nested entries (runtime)
|
||||
duration: number // calculated from nested entries (runtime)
|
||||
isFirstLinked: boolean // whether the first nested event is linked (runtime)
|
||||
revision: number
|
||||
}
|
||||
\`\`\`
|
||||
MCP entry creation accepts title plus optional colour, note, custom values and targetDuration for groups. In batch creation, a group may also include \`children\` to create child events, milestones or delays inside the group. Groups cannot be nested.
|
||||
|
||||
For existing entries, use \`ontime_group_entries\` to create a group from top-level non-group entries, and \`ontime_ungroup_entry\` to dissolve a group back into top-level entries. Use \`ontime_reorder_entry\` only for targeted moves into, out of, or around an existing group.
|
||||
|
||||
### \`milestone\` — OntimeMilestone (marker with no timer)
|
||||
\`\`\`
|
||||
{ type: 'milestone', id, cue, title, note, colour, custom, parent: EntryId | null }
|
||||
\`\`\`
|
||||
|
||||
## Time format
|
||||
Ontime stores time values as **milliseconds from midnight (local)**. Convert user-facing times before calling tools: \`10:30\` means \`37800000\`, and a duration like \`45 min\` means \`2700000\`.
|
||||
|
||||
\`timeEnd\` may be lower than \`timeStart\` when an event crosses midnight; duration is calculated across the day boundary.
|
||||
|
||||
Examples:
|
||||
- 09:00:00 = 32400000
|
||||
- 09:30:00 = 34200000
|
||||
- 14:15:00 = 51300000
|
||||
- Duration of 45 min = 2700000
|
||||
|
||||
## Custom fields
|
||||
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
|
||||
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
|
||||
|
||||
Managing field definitions:
|
||||
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
|
||||
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`.
|
||||
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
|
||||
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
|
||||
|
||||
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first.
|
||||
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
|
||||
## Targeting rundowns
|
||||
Entry read/write tools accept an optional \`rundownId\`.
|
||||
- Omit \`rundownId\` to target the currently loaded live rundown.
|
||||
- Pass a rundown ID from \`ontime_list_rundowns\` to edit a background rundown without loading it.
|
||||
- Loading a rundown with \`ontime_load_rundown\` switches the live rundown and resets runtime state; do not load a rundown just to edit it in the background.
|
||||
|
||||
## Playback states (runtime only)
|
||||
\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\`
|
||||
When playback is not \`stop\`, mutations to the loaded rundown affect the live rundown immediately, so confirm with the user before editing. Mutations to a background rundown using \`rundownId\` do not interrupt playback.
|
||||
|
||||
## Available resources
|
||||
- \`ontime://schema\` — this document
|
||||
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
|
||||
- \`ontime://rundowns\` — all rundowns in the project (JSON)
|
||||
- \`ontime://project/info\` — project metadata (JSON)
|
||||
- \`ontime://project/custom-fields\` — custom field definitions (JSON)
|
||||
- \`ontime://docs\` — index of Ontime documentation topics with URLs
|
||||
|
||||
## Further reading
|
||||
Full Ontime documentation: **https://docs.getontime.no**
|
||||
`;
|
||||
|
||||
// ---- Documentation index ----
|
||||
// Served at ontime://docs. Agents read this when they need to point users to official docs.
|
||||
|
||||
export const ONTIME_DOCS_MARKDOWN = `# Ontime Documentation Index
|
||||
|
||||
Main site: https://docs.getontime.no
|
||||
|
||||
## Getting started
|
||||
- Installation & setup: https://docs.getontime.no/installation/
|
||||
|
||||
## Core concepts
|
||||
- Rundown: https://docs.getontime.no/concepts/rundown/
|
||||
- Timer types (count-down, count-up, clock, none): https://docs.getontime.no/concepts/timer/
|
||||
- Time entry format: https://docs.getontime.no/concepts/time-entry/
|
||||
- Event actions (end action, link start): https://docs.getontime.no/concepts/event-actions/
|
||||
- Delays and blocks: https://docs.getontime.no/concepts/delays-and-blocks/
|
||||
|
||||
## Features
|
||||
- Custom fields: https://docs.getontime.no/features/custom-fields/
|
||||
- Automations (triggers, filters, outputs): https://docs.getontime.no/features/automations/
|
||||
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
|
||||
- HTTP Integration: https://docs.getontime.no/api/http/
|
||||
- OSC Integration: https://docs.getontime.no/api/osc/
|
||||
- Make custom views: https://docs.getontime.no/features/custom-views/
|
||||
|
||||
## Helpful references
|
||||
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
|
||||
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
|
||||
- Import data from spreadsheets: https://docs.getontime.no/features/import-spreadsheet/
|
||||
`;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
type CallToolResult,
|
||||
type ListToolsResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
|
||||
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
||||
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
|
||||
|
||||
export function createMcpServer(): Server {
|
||||
const server = new Server(
|
||||
{ name: 'ontime-mcp-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}),
|
||||
);
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
return handleToolCall(name, args as Record<string, unknown>);
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS }));
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
return handleGetPrompt(name, args as Record<string, string>);
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS }));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => handleReadResource(request.params.uri));
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import {
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
InsertOptions,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
addEntry,
|
||||
batchEditEntries,
|
||||
createCustomField,
|
||||
deleteCustomField,
|
||||
deleteEntries,
|
||||
editCustomField,
|
||||
editEntry,
|
||||
groupEntries,
|
||||
reorderEntry,
|
||||
ungroupEntries,
|
||||
} from '../api-data/rundown/rundown.service.js';
|
||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
export type EventFieldArgs = Partial<
|
||||
Pick<
|
||||
OntimeEvent,
|
||||
| 'cue'
|
||||
| 'title'
|
||||
| 'note'
|
||||
| 'colour'
|
||||
| 'skip'
|
||||
| 'flag'
|
||||
| 'custom'
|
||||
| 'timerType'
|
||||
| 'endAction'
|
||||
| 'linkStart'
|
||||
| 'countToEnd'
|
||||
| 'timeStrategy'
|
||||
| 'timeWarning'
|
||||
| 'timeDanger'
|
||||
| 'timeStart'
|
||||
| 'timeEnd'
|
||||
| 'duration'
|
||||
>
|
||||
>;
|
||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
||||
|
||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
||||
export type TargetRundownArgs = { rundownId?: string };
|
||||
export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` };
|
||||
export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEntryArgs[] };
|
||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
||||
|
||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||
return args.rundownId ?? getCurrentRundownId();
|
||||
}
|
||||
|
||||
function getTargetMeta(rundownId: string) {
|
||||
const loaded = rundownId === getCurrentRundownId();
|
||||
return { rundownId, loaded };
|
||||
}
|
||||
|
||||
export function getRundownById(rundownId?: string): Readonly<Rundown> {
|
||||
const targetId = rundownId ?? getCurrentRundownId();
|
||||
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
||||
}
|
||||
|
||||
export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined {
|
||||
const rundown = getRundownById(args.rundownId);
|
||||
if (args.id) {
|
||||
return rundown.entries[args.id];
|
||||
}
|
||||
if (args.cue) {
|
||||
return Object.values(rundown.entries).find((entry) => 'cue' in entry && entry.cue === args.cue);
|
||||
}
|
||||
}
|
||||
|
||||
export function toRundownList(projectRundowns: Readonly<ProjectRundowns>) {
|
||||
return { loaded: getCurrentRundownId(), rundowns: normalisedToRundownArray(projectRundowns) };
|
||||
}
|
||||
|
||||
export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['custom'] | undefined>) {
|
||||
const customFields = getProjectCustomFields();
|
||||
const knownKeys = new Set(Object.keys(customFields));
|
||||
const unknownKeys = new Set<string>();
|
||||
|
||||
for (const custom of customValues) {
|
||||
if (!custom) continue;
|
||||
for (const key of Object.keys(custom)) {
|
||||
if (!knownKeys.has(key)) {
|
||||
unknownKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownKeys.size > 0) {
|
||||
const missing = [...unknownKeys].join(', ');
|
||||
const available = Object.keys(customFields);
|
||||
const hint =
|
||||
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
|
||||
throw new Error(
|
||||
`Unknown custom field key(s): ${missing}. ${hint} ` +
|
||||
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
|
||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
|
||||
`Call ontime_get_custom_fields to list existing keys.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
||||
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
||||
const { type = SupportedEntry.Event, after, before } = args;
|
||||
|
||||
switch (type) {
|
||||
case SupportedEntry.Delay:
|
||||
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
|
||||
case SupportedEntry.Milestone: {
|
||||
const { cue, title, note, colour, custom } = args;
|
||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
|
||||
}
|
||||
case SupportedEntry.Group:
|
||||
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
||||
return { type: SupportedEntry.Group, title: args.title, after, before };
|
||||
case SupportedEntry.Event: {
|
||||
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
|
||||
return { type: SupportedEntry.Event, ...eventFields };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid entry type: ${String(type)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGroup> | null {
|
||||
const patch: PatchWithId<OntimeGroup> = { id };
|
||||
|
||||
if (args.title !== undefined) patch.title = args.title;
|
||||
if (args.note !== undefined) patch.note = args.note;
|
||||
if (args.colour !== undefined) patch.colour = args.colour;
|
||||
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
||||
if (args.custom !== undefined) patch.custom = args.custom;
|
||||
|
||||
return Object.keys(patch).length > 1 ? patch : null;
|
||||
}
|
||||
|
||||
async function updateCreatedGroup(rundownId: string, args: CreateEntryArgs, entry: OntimeEntry): Promise<OntimeEntry> {
|
||||
if (args.type !== SupportedEntry.Group) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const patch = toGroupPatch(args, entry.id);
|
||||
if (!patch) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return editEntry(rundownId, patch);
|
||||
}
|
||||
|
||||
export async function createEntryForMcp(args: CreateEntryArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const createdEntry = await addEntry(rundownId, toEntryPayload(args));
|
||||
const entry = await updateCreatedGroup(rundownId, args, createdEntry);
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function updateEntryForMcp(args: UpdateEntryArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const { rundownId: _rundownId, ...patch } = args;
|
||||
const entry = await editEntry(rundownId, patch as PatchWithId);
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await deleteEntries(rundownId, args.ids);
|
||||
return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function reorderEntryForMcp(
|
||||
args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' },
|
||||
) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
||||
return { target: getTargetMeta(rundownId), order: rundown.order };
|
||||
}
|
||||
|
||||
export async function groupEntriesForMcp(args: GroupEntriesArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = getRundownById(rundownId);
|
||||
|
||||
if (!Array.isArray(args.ids) || args.ids.length === 0) {
|
||||
throw new Error('Provide at least one entry ID to group.');
|
||||
}
|
||||
|
||||
for (const id of args.ids) {
|
||||
const entry = rundown.entries[id];
|
||||
if (!entry) {
|
||||
throw new Error(`No entry with id ${id}`);
|
||||
}
|
||||
if (entry.type === SupportedEntry.Group) {
|
||||
throw new Error(`Cannot group group entry ${id}. Groups cannot be nested.`);
|
||||
}
|
||||
if ('parent' in entry && entry.parent) {
|
||||
throw new Error(`Cannot group nested entry ${id}. Move it out of its group first.`);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRundown = await groupEntries(rundownId, args.ids);
|
||||
const group = Object.values(updatedRundown.entries).find(
|
||||
(entry): entry is OntimeGroup =>
|
||||
entry.type === SupportedEntry.Group && args.ids.every((id) => entry.entries.includes(id)),
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group operation did not create a group for the provided entries.');
|
||||
}
|
||||
|
||||
const patch = toGroupPatch(args, group.id);
|
||||
const entry = patch ? await editEntry(rundownId, patch) : group;
|
||||
|
||||
return { target: getTargetMeta(rundownId), entry, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = getRundownById(rundownId);
|
||||
const entry = rundown.entries[args.id];
|
||||
|
||||
if (!entry || entry.type !== SupportedEntry.Group) {
|
||||
throw new Error(`Group with ID ${args.id} not found or is not a group`);
|
||||
}
|
||||
|
||||
const updatedRundown = await ungroupEntries(rundownId, args.id);
|
||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function batchCreateEntriesForMcp(
|
||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
||||
) {
|
||||
const { entries = [], after } = args;
|
||||
validateBatchCreateEntries(entries);
|
||||
const allEntries = flattenBatchCreateEntries(entries);
|
||||
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
let previousId = after;
|
||||
const created: OntimeEntry[] = [];
|
||||
|
||||
for (const entryArgs of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
|
||||
created.push(...entry.created);
|
||||
previousId = entry.entry.id;
|
||||
}
|
||||
|
||||
return { target: getTargetMeta(rundownId), created };
|
||||
}
|
||||
|
||||
function flattenBatchCreateEntries(entries: BatchCreateEntryArgs[]): BatchCreateEntryArgs[] {
|
||||
return entries.flatMap((entry) => [entry, ...flattenBatchCreateEntries(entry.children ?? [])]);
|
||||
}
|
||||
|
||||
function validateBatchCreateEntries(entries: BatchCreateEntryArgs[], insideGroup = false) {
|
||||
for (const entry of entries) {
|
||||
if (insideGroup && entry.type === SupportedEntry.Group) {
|
||||
throw new Error('Cannot create a group inside another group.');
|
||||
}
|
||||
|
||||
if (entry.children?.length && entry.type !== SupportedEntry.Group) {
|
||||
throw new Error('Only group entries can have children.');
|
||||
}
|
||||
|
||||
validateBatchCreateEntries(entry.children ?? [], entry.type === SupportedEntry.Group);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBatchEntry(
|
||||
rundownId: string,
|
||||
entryArgs: BatchCreateEntryArgs,
|
||||
previousId?: EntryId,
|
||||
parentId?: EntryId,
|
||||
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
||||
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
||||
throw new Error('Cannot create a group inside another group.');
|
||||
}
|
||||
|
||||
const { children: _children, ...createArgs } = entryArgs;
|
||||
const payload = toEntryPayload(createArgs);
|
||||
const insertOptions = {
|
||||
...(previousId ? { after: previousId } : {}),
|
||||
...(parentId ? { parent: parentId } : {}),
|
||||
};
|
||||
|
||||
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
||||
const entry = await updateCreatedGroup(rundownId, createArgs, createdEntry);
|
||||
const created = [entry];
|
||||
|
||||
if (entryArgs.children?.length) {
|
||||
if (entry.type !== SupportedEntry.Group) {
|
||||
throw new Error('Only group entries can have children.');
|
||||
}
|
||||
|
||||
let previousChildId: EntryId | undefined;
|
||||
for (const childArgs of entryArgs.children) {
|
||||
// eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one
|
||||
const child = await createBatchEntry(rundownId, childArgs, previousChildId, entry.id);
|
||||
created.push(...child.created);
|
||||
previousChildId = child.entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
return { entry, created };
|
||||
}
|
||||
|
||||
export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) {
|
||||
assertKnownCustomFields(args.data.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
||||
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
|
||||
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour });
|
||||
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
|
||||
return { key, customFields: updated };
|
||||
}
|
||||
|
||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
||||
return { customFields: updated };
|
||||
}
|
||||
|
||||
export async function deleteCustomFieldForMcp(args: { key: string }) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
||||
return { customFields: updated };
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { EntryId, ProjectData } from 'ontime-types';
|
||||
|
||||
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
createNewRundown,
|
||||
deleteRundown,
|
||||
duplicateExistingRundown,
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
} from '../api-data/rundown/rundown.service.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { makeNewProject } from '../models/dataModel.js';
|
||||
import {
|
||||
createProjectWithPatch,
|
||||
deleteProjectFile,
|
||||
duplicateProjectFile,
|
||||
getProjectList,
|
||||
loadProjectFile,
|
||||
renameProjectFile,
|
||||
} from '../services/project-service/ProjectService.js';
|
||||
import { getState } from '../stores/runtimeState.js';
|
||||
import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
||||
import {
|
||||
batchCreateEntriesForMcp,
|
||||
batchUpdateEntriesForMcp,
|
||||
createCustomFieldForMcp,
|
||||
createEntryForMcp,
|
||||
deleteCustomFieldForMcp,
|
||||
deleteEntriesForMcp,
|
||||
findEntry,
|
||||
getRundownById,
|
||||
groupEntriesForMcp,
|
||||
reorderEntryForMcp,
|
||||
toRundownList,
|
||||
ungroupEntryForMcp,
|
||||
updateCustomFieldForMcp,
|
||||
updateEntryForMcp,
|
||||
type BatchCreateEntryArgs,
|
||||
type CreateEntryArgs,
|
||||
type EntryFieldArgs,
|
||||
type GroupEntriesArgs,
|
||||
type TargetRundownArgs,
|
||||
type UngroupEntryArgs,
|
||||
type UpdateEntryArgs,
|
||||
} from './mcp.service.js';
|
||||
|
||||
// Graceful truncation to keep tool responses within typical MCP context windows
|
||||
const CHARACTER_LIMIT = 25_000;
|
||||
|
||||
// ---- MCP tool annotation presets ----
|
||||
// https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
|
||||
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;
|
||||
const WRITE_IDEM = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||
const WRITE_DESTRUCTIVE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
// ---- Tool definitions ----
|
||||
export const TOOL_DEFINITIONS = [
|
||||
// --- Rundown read ---
|
||||
{
|
||||
name: 'ontime_get_rundown',
|
||||
description:
|
||||
'Get a rundown. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual entries with ontime_get_entry.',
|
||||
inputSchema: { type: 'object', properties: { ...RUNDOWN_TARGET_FIELD } },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_rundown_metadata',
|
||||
description:
|
||||
'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_entry',
|
||||
description:
|
||||
'Get a single entry by id or cue. Provide either id or cue (not both). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns the full entry object.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Entry ID (from rundown.entries key or entry.id)' },
|
||||
cue: { type: 'string', description: 'Human-facing cue label' },
|
||||
},
|
||||
},
|
||||
annotations: READ,
|
||||
},
|
||||
// --- Rundown mutations ---
|
||||
{
|
||||
name: 'ontime_create_entry',
|
||||
description:
|
||||
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description:
|
||||
'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight (e.g. 09:00 = 32400000)' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: {
|
||||
type: 'number',
|
||||
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
after: { type: 'string', description: 'Insert after this entry ID' },
|
||||
before: { type: 'string', description: 'Insert before this entry ID' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_entry',
|
||||
description:
|
||||
'Update fields of an existing entry (event, milestone, delay or group). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'ID of the entry to update' },
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_entries',
|
||||
description:
|
||||
'Delete one or more entries (events, milestones, delays, or groups). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_reorder_entry',
|
||||
description:
|
||||
'Move an entry to a new position relative to another entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use before/after for sibling reordering; use insert for targeted moves into a group. For grouping several existing top-level entries, prefer ontime_group_entries.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entryId', 'destinationId', 'order'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
entryId: { type: 'string', description: 'ID of the entry to move' },
|
||||
destinationId: { type: 'string', description: 'ID of the target entry (sibling or parent group)' },
|
||||
order: {
|
||||
type: 'string',
|
||||
enum: ['before', 'after', 'insert'],
|
||||
description: 'before/after: place as sibling; insert: place inside a group',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_group_entries',
|
||||
description:
|
||||
'Create a group from existing top-level entries. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Entries must be existing top-level non-group entries; groups cannot be nested. Optional title, note, colour, custom, and targetDuration are applied to the created group.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
|
||||
title: { type: 'string', description: 'Group title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text group note for production notes or references' },
|
||||
colour: { type: 'string', description: 'Hex colour (#RRGGBB) for the group' },
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Custom field values keyed by existing project field key',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Planned length of the group in ms' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_ungroup_entry',
|
||||
description:
|
||||
'Dissolve a group by moving its children to the top level where the group was. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Group entry ID to dissolve' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_create_entries',
|
||||
description:
|
||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entries'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
|
||||
entries: {
|
||||
type: 'array',
|
||||
description: 'Array of entries to create, in desired order',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description: 'Entry type, defaults to event',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
children: {
|
||||
type: 'array',
|
||||
description:
|
||||
'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_update_entries',
|
||||
description:
|
||||
'Apply the same field values to multiple entries by ID. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids', 'data'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to update' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Partial entry fields to apply to every ID',
|
||||
properties: {
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Rundown management ---
|
||||
{
|
||||
name: 'ontime_list_rundowns',
|
||||
description:
|
||||
'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_rundown',
|
||||
description:
|
||||
'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string', description: 'Title for the new rundown' } },
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_rundown',
|
||||
description:
|
||||
'Make a rundown the active rundown. This resets the runtime and clears playback state. If playback is running, confirm the user accepts interrupting the live rundown before calling. To edit a background rundown without interrupting playback, advise using the cuesheet view.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to load' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_rundown',
|
||||
description: 'Rename an existing rundown',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id', 'title'],
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Rundown ID to rename' },
|
||||
title: { type: 'string', description: 'New title' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_rundown',
|
||||
description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to delete' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_rundown',
|
||||
description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } },
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
// --- Timer & project ---
|
||||
{
|
||||
name: 'ontime_get_timer_state',
|
||||
description:
|
||||
'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_project_info',
|
||||
description:
|
||||
'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_project_info',
|
||||
description: 'Update project metadata fields. All fields are optional — only provided fields are updated.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Project title' },
|
||||
description: { type: 'string', description: 'Project description' },
|
||||
url: { type: 'string', description: 'URL shown on viewer pages' },
|
||||
info: { type: 'string', description: 'Info text shown on viewer pages' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_custom_fields',
|
||||
description:
|
||||
'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in entry.custom[key].',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_custom_field',
|
||||
description:
|
||||
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['label', 'type', 'colour'],
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['text', 'image'],
|
||||
description:
|
||||
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
|
||||
},
|
||||
colour: {
|
||||
type: 'string',
|
||||
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_custom_field',
|
||||
description:
|
||||
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
|
||||
},
|
||||
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_custom_field',
|
||||
description:
|
||||
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Project file management ---
|
||||
{
|
||||
name: 'ontime_list_projects',
|
||||
description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_project',
|
||||
description:
|
||||
'Load a different project file by filename. This swaps the database and reinitialises runtime. If playback is running, confirm the user accepts interrupting the live project before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_project',
|
||||
description:
|
||||
'Create a new project file and switch to it. This swaps the loaded project. If playback is running, confirm the user accepts interrupting the live project before calling. Omit the .json extension — Ontime appends it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' },
|
||||
title: { type: 'string', description: 'Optional project title' },
|
||||
description: { type: 'string', description: 'Optional project description' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_project',
|
||||
description: 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Current filename (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'New filename (with .json extension)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_project',
|
||||
description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Source filename to copy (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_project',
|
||||
description: 'Delete a project file from disk. Fails if the file is currently loaded.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ToolName = (typeof TOOL_DEFINITIONS)[number]['name'];
|
||||
|
||||
type ProjectInfoArgs = Partial<Pick<ProjectData, 'title' | 'description' | 'url' | 'info'>>;
|
||||
|
||||
// ---- Response helpers (module-level to avoid re-allocation on every tool call) ----
|
||||
|
||||
const text = (data: unknown): string => JSON.stringify(data);
|
||||
|
||||
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
|
||||
|
||||
export const err = (e: unknown): CallToolResult => ({
|
||||
content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// ---- Tool handlers ----
|
||||
// Each handler is a thin translation wrapper: it maps the wire arguments to a typed call
|
||||
// into an existing service and formats the response. Business logic belongs in the services.
|
||||
const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise<CallToolResult>> = {
|
||||
ontime_get_rundown: async (args) => {
|
||||
const targetArgs = args as TargetRundownArgs;
|
||||
const rundown = getRundownById(targetArgs.rundownId);
|
||||
const data = { order: rundown.order, entries: rundown.entries };
|
||||
const serialised = text(data);
|
||||
if (serialised.length > CHARACTER_LIMIT) {
|
||||
return ok({
|
||||
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry.`,
|
||||
truncated: true,
|
||||
rundownId: rundown.id,
|
||||
order: rundown.order,
|
||||
});
|
||||
}
|
||||
return ok({ rundownId: rundown.id, ...data });
|
||||
},
|
||||
|
||||
ontime_get_rundown_metadata: async () => ok(getRundownMetadata()),
|
||||
|
||||
ontime_get_entry: async (args) => {
|
||||
const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string };
|
||||
const entry = findEntry(entryArgs);
|
||||
if (entry) return ok(entry);
|
||||
if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`);
|
||||
if (entryArgs.cue) return err(`No entry with cue ${entryArgs.cue}`);
|
||||
return err('Provide id or cue');
|
||||
},
|
||||
|
||||
ontime_create_entry: async (args) => {
|
||||
return ok(await createEntryForMcp(args as CreateEntryArgs));
|
||||
},
|
||||
|
||||
ontime_update_entry: async (args) => {
|
||||
return ok(await updateEntryForMcp(args as UpdateEntryArgs));
|
||||
},
|
||||
|
||||
ontime_delete_entries: async (args) => {
|
||||
return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] }));
|
||||
},
|
||||
|
||||
ontime_reorder_entry: async (args) => {
|
||||
return ok(
|
||||
await reorderEntryForMcp(
|
||||
args as TargetRundownArgs & {
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
ontime_group_entries: async (args) => {
|
||||
return ok(await groupEntriesForMcp(args as GroupEntriesArgs));
|
||||
},
|
||||
|
||||
ontime_ungroup_entry: async (args) => {
|
||||
return ok(await ungroupEntryForMcp(args as UngroupEntryArgs));
|
||||
},
|
||||
|
||||
ontime_batch_create_entries: async (args) => {
|
||||
return ok(
|
||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
||||
);
|
||||
},
|
||||
|
||||
ontime_batch_update_entries: async (args) => {
|
||||
return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }));
|
||||
},
|
||||
|
||||
ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())),
|
||||
|
||||
ontime_create_rundown: async (args) => {
|
||||
const { title } = args as { title: string };
|
||||
return ok(toRundownList(await createNewRundown(title)));
|
||||
},
|
||||
|
||||
ontime_load_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await loadRundown(id)));
|
||||
},
|
||||
|
||||
ontime_rename_rundown: async (args) => {
|
||||
const { id, title } = args as { id: string; title: string };
|
||||
return ok(toRundownList(await renameRundown(id, title)));
|
||||
},
|
||||
|
||||
ontime_delete_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await deleteRundown(id)));
|
||||
},
|
||||
|
||||
ontime_duplicate_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await duplicateExistingRundown(id)));
|
||||
},
|
||||
|
||||
ontime_get_timer_state: async () => {
|
||||
const { clock, timer, eventNow, eventNext, offset } = getState();
|
||||
return ok({ clock, timer, eventNow, eventNext, offset });
|
||||
},
|
||||
|
||||
ontime_get_project_info: async () => ok(getProjectData()),
|
||||
|
||||
ontime_update_project_info: async (args) => {
|
||||
const updated = await editCurrentProjectData(args as ProjectInfoArgs);
|
||||
return ok(updated);
|
||||
},
|
||||
|
||||
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
|
||||
|
||||
ontime_create_custom_field: async (args) => {
|
||||
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
|
||||
},
|
||||
|
||||
ontime_update_custom_field: async (args) => {
|
||||
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
|
||||
},
|
||||
|
||||
ontime_delete_custom_field: async (args) => {
|
||||
return ok(await deleteCustomFieldForMcp(args as { key: string }));
|
||||
},
|
||||
|
||||
ontime_list_projects: async () => ok(await getProjectList()),
|
||||
|
||||
ontime_load_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
await loadProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_create_project: async (args) => {
|
||||
const {
|
||||
filename,
|
||||
title = '',
|
||||
description = '',
|
||||
} = args as {
|
||||
filename: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
const project: ProjectData = { ...makeNewProject().project, title, description };
|
||||
const newFileName = await createProjectWithPatch(filename, { project });
|
||||
return ok({ filename: newFileName });
|
||||
},
|
||||
|
||||
ontime_rename_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
await renameProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_duplicate_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
await duplicateProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_delete_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
await deleteProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Tool call dispatcher ----
|
||||
export async function handleToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
||||
const handler = TOOL_HANDLERS[name as ToolName];
|
||||
if (!handler) {
|
||||
return err(`Unknown tool: ${name}`);
|
||||
}
|
||||
try {
|
||||
return await handler(args);
|
||||
} catch (error) {
|
||||
return err(error);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
import { logger } from './classes/Logger.js';
|
||||
@@ -100,6 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
|
||||
@@ -101,9 +101,10 @@ function getCustomFields(): Readonly<CustomFields> {
|
||||
return db.data.customFields;
|
||||
}
|
||||
|
||||
async function setRundown(rundownKey: string, newData: Rundown): Promise<void> {
|
||||
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
|
||||
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||
await persist();
|
||||
return db.data.rundowns;
|
||||
}
|
||||
|
||||
function getSettings(): Readonly<Settings> {
|
||||
|
||||
@@ -87,8 +87,8 @@ describe('safeMerge', () => {
|
||||
it('should merge the urlPresets key when present', () => {
|
||||
const newData = {
|
||||
urlPresets: [
|
||||
{ enabled: true, alias: 'alias1', search: '' },
|
||||
{ enabled: true, alias: 'alias2', search: '' },
|
||||
{ enabled: true, alias: 'alias1', target: 'timer', search: '', displayInNav: false },
|
||||
{ enabled: true, alias: 'alias2', target: 'timer', search: '', displayInNav: false },
|
||||
] as URLPreset[],
|
||||
};
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export const demoDb: DatabaseModel = {
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true',
|
||||
displayInNav: false,
|
||||
},
|
||||
{
|
||||
enabled: true,
|
||||
@@ -50,6 +51,7 @@ export const demoDb: DatabaseModel = {
|
||||
target: OntimeView.Timer,
|
||||
search:
|
||||
'hideclock=true&hidecards=true&hideprogress=true&hidemessage=true&hidesecondary=true&hidelogo=true&font=arial+black&keycolour=00ff00&timerColour=ffffff',
|
||||
displayInNav: false,
|
||||
},
|
||||
],
|
||||
customFields: {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { MessageTag, OffsetMode, TimerLifeCycle } from 'ontime-types';
|
||||
/**
|
||||
* Characterisation tests for the broadcast contract of the RuntimeService
|
||||
*
|
||||
* These tests lock down WHICH top-level keys of the runtime store are
|
||||
* emitted to clients for each action and tick. This is the wire contract
|
||||
* consumed by the client (which shallow-merges top-level keys) and by
|
||||
* user-facing automation templates.
|
||||
*
|
||||
* The tests drive the real production path: fake timers fire the
|
||||
* EventTimer interval, which runs update() and the broadcastResult
|
||||
* decorator diffing, down to the (spied) websocket fan-out.
|
||||
*/
|
||||
import type { MockInstance } from 'vitest';
|
||||
|
||||
import { makeFlatRundown } from '../../../stores/__tests__/harness/scenario.fixtures.js';
|
||||
|
||||
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../restore-service/restore.service.js', () => {
|
||||
return {
|
||||
restoreService: {
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
load: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
create: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../api-data/automation/automation.service.js', () => {
|
||||
return {
|
||||
triggerAutomations: vi.fn(),
|
||||
testOutput: vi.fn(),
|
||||
testConditions: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../api-data/report/report.service.js', () => {
|
||||
return {
|
||||
generate: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
triggerReportEntry: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
type RuntimeServiceModule = typeof import('../runtime.service.js');
|
||||
type AutomationModule = typeof import('../../../api-data/automation/automation.service.js');
|
||||
type RestoreModule = typeof import('../../restore-service/restore.service.js');
|
||||
|
||||
let runtimeService: RuntimeServiceModule['runtimeService'];
|
||||
let triggerAutomations: AutomationModule['triggerAutomations'];
|
||||
let restoreService: RestoreModule['restoreService'];
|
||||
let sendSpy: MockInstance;
|
||||
|
||||
/** RuntimeData patches sent over the websocket since the last spy reset */
|
||||
function runtimePatches(): Record<string, unknown>[] {
|
||||
return sendSpy.mock.calls
|
||||
.filter(([tag]) => tag === MessageTag.RuntimeData)
|
||||
.map(([, payload]) => payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** the sorted top-level keys of every emitted patch */
|
||||
function emittedKeys(): string[][] {
|
||||
return runtimePatches().map((patch) => Object.keys(patch).sort());
|
||||
}
|
||||
|
||||
/** drains the process.nextTick queue where automation triggers are scheduled */
|
||||
async function flushNextTicks() {
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// the runtime service starts its EventTimer interval at import time:
|
||||
// reset modules and install fake timers BEFORE importing, so the
|
||||
// interval is fake and controlled by the test
|
||||
vi.resetModules();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime('jan 5 09:59');
|
||||
|
||||
const websocketModule = await import('../../../adapters/WebsocketAdapter.js');
|
||||
sendSpy = vi.spyOn(websocketModule.socket, 'sendAsJson');
|
||||
|
||||
const automationModule = await import('../../../api-data/automation/automation.service.js');
|
||||
triggerAutomations = automationModule.triggerAutomations;
|
||||
|
||||
const restoreModule = await import('../../restore-service/restore.service.js');
|
||||
restoreService = restoreModule.restoreService;
|
||||
|
||||
const serviceModule = await import('../runtime.service.js');
|
||||
runtimeService = serviceModule.runtimeService;
|
||||
runtimeService.init(null);
|
||||
|
||||
const { initRundown } = await import('../../../api-data/rundown/rundown.service.js');
|
||||
await initRundown(makeFlatRundown(), {});
|
||||
// flush the scheduled rundown side effects
|
||||
// (we cannot use runAllTimers here, the EventTimer interval never stops)
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
sendSpy.mockClear();
|
||||
vi.mocked(restoreService.save).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
runtimeService.shutdown();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/**
|
||||
* !!! characterised bug: the decorator combines the entry diffing with
|
||||
* `entryChanged ||= updateMaybeEntryIfChanged(key)`. The logical-or
|
||||
* assignment short-circuits: once one entry has changed, the remaining
|
||||
* entries are neither diffed nor emitted in that broadcast. Changed
|
||||
* entries therefore drip out one per broadcast cycle
|
||||
* (load emits eventNow; eventNext rides along on the next broadcast,
|
||||
* eventFlag on the one after)
|
||||
*/
|
||||
describe('characterisation: broadcast contract', () => {
|
||||
test('loading an event emits eventNow, timer, clock and rundown', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
|
||||
// eventNext and eventFlag also changed, but are held back by the short-circuit
|
||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'rundown', 'timer']]);
|
||||
// an immediate change also saves a restore point
|
||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('starting emits timer, clock, offset and rundown', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
sendSpy.mockClear();
|
||||
|
||||
runtimeService.start();
|
||||
|
||||
// offset is included because the expected times are computed on start
|
||||
// eventNext is the pending entry dripping out from the load
|
||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNext', 'offset', 'rundown', 'timer']]);
|
||||
});
|
||||
|
||||
test('ticks within the same second only flush the pending entry', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
sendSpy.mockClear();
|
||||
|
||||
// the EventTimer interval fires every 32ms: run 5 production ticks
|
||||
vi.advanceTimersByTime(5 * 32);
|
||||
|
||||
// nothing tick-related is emitted within the same second: the only
|
||||
// patch is the eventFlag entry still dripping out from the load
|
||||
expect(emittedKeys()).toStrictEqual([['eventFlag']]);
|
||||
});
|
||||
|
||||
test('a tick crossing the second boundary emits timer and clock together', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
sendSpy.mockClear();
|
||||
|
||||
// 32 interval ticks: the seconds boundary is crossed at the 1024ms tick
|
||||
vi.advanceTimersByTime(1024);
|
||||
|
||||
// first tick flushes the dripping eventFlag; the boundary tick emits
|
||||
// timer and clock; offset is not included since it did not change
|
||||
expect(emittedKeys()).toStrictEqual([['eventFlag'], ['clock', 'timer']]);
|
||||
});
|
||||
|
||||
test('addTime emits timer, clock and offset immediately', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
sendSpy.mockClear();
|
||||
vi.mocked(restoreService.save).mockClear();
|
||||
|
||||
runtimeService.addTime(60_000);
|
||||
|
||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'offset', 'timer']]);
|
||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('setOffsetMode emits the offset mid-second', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
sendSpy.mockClear();
|
||||
vi.mocked(restoreService.save).mockClear();
|
||||
|
||||
runtimeService.setOffsetMode(OffsetMode.Relative);
|
||||
|
||||
expect(emittedKeys()).toStrictEqual([['eventFlag', 'offset']]);
|
||||
// the mode change counts as an immediate change and saves a restore point
|
||||
expect(restoreService.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('pause emits timer and clock; stop emits the clearing state', () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
sendSpy.mockClear();
|
||||
|
||||
runtimeService.pause();
|
||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventFlag', 'timer']]);
|
||||
|
||||
sendSpy.mockClear();
|
||||
runtimeService.stop();
|
||||
// eventNow clears to null; the other cleared entries are again held
|
||||
// back by the entry short-circuit
|
||||
expect(emittedKeys()).toStrictEqual([['clock', 'eventNow', 'offset', 'rundown', 'timer']]);
|
||||
});
|
||||
|
||||
test('integration automations are throttled to the seconds rollover', async () => {
|
||||
runtimeService.loadById('flat1');
|
||||
vi.setSystemTime('jan 5 10:00');
|
||||
runtimeService.start();
|
||||
await flushNextTicks();
|
||||
vi.mocked(triggerAutomations).mockClear();
|
||||
|
||||
// the first tick initialises the integration trackers and fires both cycles
|
||||
vi.advanceTimersByTime(32);
|
||||
await flushNextTicks();
|
||||
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
|
||||
[TimerLifeCycle.onUpdate],
|
||||
[TimerLifeCycle.onClock],
|
||||
]);
|
||||
vi.mocked(triggerAutomations).mockClear();
|
||||
|
||||
// ticks within the same second do not trigger integrations
|
||||
vi.advanceTimersByTime(5 * 32);
|
||||
await flushNextTicks();
|
||||
expect(triggerAutomations).not.toHaveBeenCalled();
|
||||
|
||||
// crossing into the next second triggers onUpdate and onClock once
|
||||
vi.advanceTimersByTime(832);
|
||||
await flushNextTicks();
|
||||
expect(vi.mocked(triggerAutomations).mock.calls).toStrictEqual([
|
||||
[TimerLifeCycle.onUpdate],
|
||||
[TimerLifeCycle.onClock],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Characterisation tests for the change-detection predicates that gate
|
||||
* what gets broadcast to clients on every tick
|
||||
*/
|
||||
import { Offset, OffsetMode, PlayableEvent, Playback, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||
import type { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import {
|
||||
collectRuntimeStateChanges,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from '../runtime.utils.js';
|
||||
|
||||
const baseTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
current: 10_000,
|
||||
duration: 60_000,
|
||||
elapsed: 50_000,
|
||||
expectedFinish: 100_000,
|
||||
phase: TimerPhase.Default,
|
||||
playback: Playback.Play,
|
||||
secondaryTimer: null,
|
||||
startedAt: 1000,
|
||||
};
|
||||
|
||||
function makeTimer(patch: Partial<TimerState>): TimerState {
|
||||
return { ...baseTimer, ...patch };
|
||||
}
|
||||
|
||||
const baseOffset: Offset = {
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
expectedGroupEnd: null,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
};
|
||||
|
||||
describe('isNewSecond()', () => {
|
||||
describe('count down (default): seconds are rounded up', () => {
|
||||
test.each([
|
||||
// [previous, current, expected]
|
||||
[1500, 1200, false], // both round to 2
|
||||
[1500, 1000, true], // 2 -> 1
|
||||
[1000, 999, false], // both round to 1
|
||||
[999, 1, false], // both round to 1
|
||||
[1, 0, true], // 1 -> 0
|
||||
[0, -999, false], // both round to 0
|
||||
[-1, -1000, true], // 0 -> -1
|
||||
])('%s -> %s is a new second: %s', (previous, current, expected) => {
|
||||
expect(isNewSecond(previous, current)).toBe(expected);
|
||||
});
|
||||
|
||||
test('null and undefined values are coerced to second 0', () => {
|
||||
expect(isNewSecond(undefined, 0)).toBe(false);
|
||||
expect(isNewSecond(null, 0)).toBe(false);
|
||||
expect(isNewSecond(undefined, 1000)).toBe(true);
|
||||
expect(isNewSecond(0, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count up: seconds are rounded down', () => {
|
||||
test.each([
|
||||
[999, 1, false], // both round to 0
|
||||
[999, 1000, true], // 0 -> 1
|
||||
[1000, 1999, false], // both round to 1
|
||||
])('%s -> %s is a new second: %s', (previous, current, expected) => {
|
||||
expect(isNewSecond(previous, current, TimerType.CountUp)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldClockUpdate()', () => {
|
||||
test('updates when the clock rolls into a new second', () => {
|
||||
expect(getShouldClockUpdate(500, 999)).toBe(false);
|
||||
expect(getShouldClockUpdate(999, 1000)).toBe(true);
|
||||
expect(getShouldClockUpdate(1000, 1032)).toBe(false);
|
||||
});
|
||||
|
||||
test('updates when the clock wraps around midnight', () => {
|
||||
expect(getShouldClockUpdate(86_399_999, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldTimerUpdate()', () => {
|
||||
test('always updates when there is no previous state', () => {
|
||||
expect(getShouldTimerUpdate(undefined, baseTimer)).toBe(true);
|
||||
});
|
||||
|
||||
test('does not update when nothing changed', () => {
|
||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({}))).toBe(false);
|
||||
});
|
||||
|
||||
test('current triggers only on a new second', () => {
|
||||
// 10_000 and 9_001 both round up to second 10
|
||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9001 }))).toBe(false);
|
||||
expect(getShouldTimerUpdate(baseTimer, makeTimer({ current: 9000 }))).toBe(true);
|
||||
});
|
||||
|
||||
test('secondaryTimer triggers only on a new second', () => {
|
||||
const previous = makeTimer({ secondaryTimer: 5000 });
|
||||
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4001 }))).toBe(false);
|
||||
expect(getShouldTimerUpdate(previous, makeTimer({ secondaryTimer: 4000 }))).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['addedTime', { addedTime: 1000 }],
|
||||
['duration', { duration: 61_000 }],
|
||||
['phase', { phase: TimerPhase.Warning }],
|
||||
['playback', { playback: Playback.Pause }],
|
||||
['startedAt', { startedAt: 2000 }],
|
||||
] as const)('%s triggers on any change', (_field, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['elapsed', { elapsed: 50_500 }],
|
||||
['expectedFinish', { expectedFinish: 100_500 }],
|
||||
] as const)('%s is deliberately not checked', (_field, patch) => {
|
||||
expect(getShouldTimerUpdate(baseTimer, makeTimer(patch))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldOffsetUpdate()', () => {
|
||||
test('always updates when there is no previous state', () => {
|
||||
expect(getShouldOffsetUpdate(undefined, baseOffset, false)).toBe(true);
|
||||
});
|
||||
|
||||
test('mode change triggers regardless of dependencies', () => {
|
||||
const current = { ...baseOffset, mode: OffsetMode.Relative };
|
||||
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(true);
|
||||
});
|
||||
|
||||
test('value changes are gated by the dependency update', () => {
|
||||
const current = { ...baseOffset, absolute: 1000 };
|
||||
// a changed offset without a timer/clock update is not emitted
|
||||
expect(getShouldOffsetUpdate(baseOffset, current, false)).toBe(false);
|
||||
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
|
||||
});
|
||||
|
||||
test('does not update when deep-equal, even with a dependency update', () => {
|
||||
expect(getShouldOffsetUpdate(baseOffset, { ...baseOffset }, true)).toBe(false);
|
||||
});
|
||||
|
||||
test('expected times participate in the deep comparison', () => {
|
||||
const current = { ...baseOffset, expectedRundownEnd: 1000 };
|
||||
expect(getShouldOffsetUpdate(baseOffset, current, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectRuntimeStateChanges()', () => {
|
||||
test('the very first run emits everything and counts as an immediate change', () => {
|
||||
const previousState = {} as RuntimeState;
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'offset', 'rundown', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
// the emitted keys are persisted into the previous state for the next diff
|
||||
expect(previousState.timer).toStrictEqual(state.timer);
|
||||
expect(previousState.offset).toStrictEqual(state.offset);
|
||||
});
|
||||
|
||||
test('an unchanged state emits nothing', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(patch).toStrictEqual({});
|
||||
expect(hasImmediateChanges).toBe(false);
|
||||
});
|
||||
|
||||
test('a playback change emits timer and clock together', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ timer: { playback: Playback.Armed } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
|
||||
test('an offset change is gated on a timer/clock dependency', () => {
|
||||
// nothing else changed: the offset is held back
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ offset: { absolute: 1000 } });
|
||||
|
||||
const gated = collectRuntimeStateChanges(previousState, state);
|
||||
expect(gated.patch).toStrictEqual({});
|
||||
|
||||
// with a clock rollover, the offset rides along
|
||||
const stateWithClock = makeRuntimeStateData({ clock: 1000, offset: { absolute: 1000 } });
|
||||
const emitted = collectRuntimeStateChanges(previousState, stateWithClock);
|
||||
expect(Object.keys(emitted.patch).sort()).toStrictEqual(['clock', 'offset']);
|
||||
});
|
||||
|
||||
test('!!! characterised bug: changed entries drip out one per call', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const eventNow = makeOntimeEvent({ id: 'now' }) as PlayableEvent;
|
||||
const eventNext = makeOntimeEvent({ id: 'next' }) as PlayableEvent;
|
||||
const state = makeRuntimeStateData({ eventNow, eventNext });
|
||||
|
||||
// both entries changed, but the ||= short-circuit only diffs the first
|
||||
const first = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(first.patch).sort()).toStrictEqual(['eventNow']);
|
||||
|
||||
// the second call flushes the next pending entry
|
||||
const second = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(second.patch).sort()).toStrictEqual(['eventNext']);
|
||||
|
||||
// from here on, nothing is pending
|
||||
const third = collectRuntimeStateChanges(previousState, state);
|
||||
expect(third.patch).toStrictEqual({});
|
||||
});
|
||||
|
||||
test('rundown data changes are emitted on deep difference', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ rundown: { actualStart: 1000 } });
|
||||
|
||||
const { patch } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['rundown']);
|
||||
});
|
||||
|
||||
test('addedTime changes count as immediate and emit the timer', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ timer: { addedTime: 60_000 } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['clock', 'timer']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
|
||||
test('an offset mode change is immediate and bypasses the dependency gate', () => {
|
||||
const previousState = makeRuntimeStateData();
|
||||
const state = makeRuntimeStateData({ offset: { mode: OffsetMode.Relative } });
|
||||
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(previousState, state);
|
||||
expect(Object.keys(patch).sort()).toStrictEqual(['offset']);
|
||||
expect(hasImmediateChanges).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import {
|
||||
EndAction,
|
||||
EntryId,
|
||||
@@ -30,13 +29,12 @@ import { restoreService } from '../restore-service/restore.service.js';
|
||||
import type { RestorePoint } from '../restore-service/restore.type.js';
|
||||
import { skippedOutOfEvent } from '../timerUtils.js';
|
||||
import {
|
||||
collectRuntimeStateChanges,
|
||||
findNextPlayableId,
|
||||
findNextPlayableWithCue,
|
||||
findPreviousPlayableId,
|
||||
getEventAtIndex,
|
||||
getShouldClockUpdate,
|
||||
getShouldOffsetUpdate,
|
||||
getShouldTimerUpdate,
|
||||
isNewSecond,
|
||||
} from './runtime.utils.js';
|
||||
|
||||
@@ -651,8 +649,6 @@ const eventTimer = new EventTimer({
|
||||
});
|
||||
export const runtimeService = new RuntimeService(eventTimer);
|
||||
|
||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||
|
||||
/**
|
||||
* Decorator manages side effects from updating the runtime
|
||||
* This should only be applied to functions that are exposed for consumption
|
||||
@@ -665,90 +661,13 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// call the original method and get the state
|
||||
const result = originalMethod.apply(this, args);
|
||||
const state = runtimeState.getState();
|
||||
|
||||
// diff against the previously broadcast state to find what to send
|
||||
const { patch, hasImmediateChanges } = collectRuntimeStateChanges(RuntimeService.previousState, state);
|
||||
|
||||
const batch = eventStore.createBatch();
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// Update the entry if they have changed
|
||||
let entryChanged = false;
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !RuntimeService.previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = RuntimeService.previousState?.offset?.mode !== state.offset.mode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
const addedTimeChanged = !justStarted && RuntimeService.previousState?.timer.addedTime !== state.timer.addedTime;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges =
|
||||
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are updated when the seconds roll over
|
||||
*/
|
||||
const updateTimer = getShouldTimerUpdate(RuntimeService.previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
batch.add('timer', state.timer);
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
/**
|
||||
* clock has changed by a second or more.
|
||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||
*/
|
||||
const updateClock = updateTimer || getShouldClockUpdate(RuntimeService.previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
batch.add('clock', state.clock);
|
||||
RuntimeService.previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||
*/
|
||||
const updateRuntime = getShouldOffsetUpdate(
|
||||
RuntimeService.previousState?.offset,
|
||||
state.offset,
|
||||
updateClock || hasImmediateChanges,
|
||||
);
|
||||
if (updateRuntime) {
|
||||
batch.add('offset', state.offset);
|
||||
RuntimeService.previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
*/
|
||||
const updateRundownData = !deepEqual(RuntimeService.previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
batch.add('rundown', state.rundown);
|
||||
RuntimeService.previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||
const previousEntry = RuntimeService.previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||
|
||||
// if they have the same id the check if the contents have changed
|
||||
if (previousEntry?.id === currentEntry?.id) {
|
||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
batch.add(key, currentEntry as RuntimeStore[K]); // we know that there is the necessary overlap in the types to cast this
|
||||
RuntimeService.previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
for (const key of Object.keys(patch) as (keyof RuntimeStore)[]) {
|
||||
batch.add(key, patch[key] as RuntimeStore[typeof key]);
|
||||
}
|
||||
|
||||
// save the restore state
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Offset,
|
||||
OntimeEvent,
|
||||
Rundown,
|
||||
RuntimeStore,
|
||||
TimerState,
|
||||
TimerType,
|
||||
isOntimeEvent,
|
||||
@@ -12,6 +13,8 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { millisToSeconds } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
|
||||
export function isNewSecond(
|
||||
previousValue: MaybeNumber | undefined,
|
||||
currentValue: MaybeNumber | undefined,
|
||||
@@ -68,6 +71,104 @@ export function getShouldOffsetUpdate(
|
||||
return didDependencyUpdate && !deepEqual(previousValue, currentValue);
|
||||
}
|
||||
|
||||
type EntryUpdateKeys = keyof Pick<RuntimeState, 'eventNow' | 'eventNext' | 'eventFlag' | 'groupNow'>;
|
||||
|
||||
/**
|
||||
* Diffs a runtime state snapshot against the previously broadcast state
|
||||
* and collects the store keys that should be sent to clients
|
||||
*
|
||||
* !!! mutates previousState in place: emitted keys are persisted so the
|
||||
* next diff compares against what clients last received
|
||||
*/
|
||||
export function collectRuntimeStateChanges(
|
||||
previousState: RuntimeState,
|
||||
state: Readonly<RuntimeState>,
|
||||
): { patch: Partial<RuntimeStore>; hasImmediateChanges: boolean } {
|
||||
const patch: Partial<RuntimeStore> = {};
|
||||
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// Update the entry if they have changed
|
||||
let entryChanged = false;
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNow');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventNext');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('eventFlag');
|
||||
entryChanged ||= updateMaybeEntryIfChanged('groupNow');
|
||||
|
||||
// for the very fist run there will be nothing in the previousState so we force an update
|
||||
const justStarted = !previousState?.timer;
|
||||
|
||||
// offset mode has been changed
|
||||
const offsetModeChanged = previousState?.offset?.mode !== state.offset.mode;
|
||||
|
||||
// if playback changes most things should update
|
||||
const hasChangedPlayback = previousState.timer?.playback !== state.timer.playback;
|
||||
|
||||
const addedTimeChanged = !justStarted && previousState?.timer.addedTime !== state.timer.addedTime;
|
||||
|
||||
// combine all big changes
|
||||
const hasImmediateChanges =
|
||||
entryChanged || justStarted || hasChangedPlayback || offsetModeChanged || addedTimeChanged;
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are updated when the seconds roll over
|
||||
*/
|
||||
const updateTimer = getShouldTimerUpdate(previousState?.timer, state.timer);
|
||||
if (updateTimer) {
|
||||
patch.timer = state.timer;
|
||||
previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
/**
|
||||
* clock has changed by a second or more.
|
||||
* or the timer updated so we ensure that the timer and clock ticks are in sync
|
||||
*/
|
||||
const updateClock = updateTimer || getShouldClockUpdate(previousState.clock, state.clock);
|
||||
if (updateClock) {
|
||||
patch.clock = state.clock;
|
||||
previousState.clock = state.clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
* values that have the possibility to tick are modulated by `updateClock || hasImmediateChanges`
|
||||
*/
|
||||
const updateRuntime = getShouldOffsetUpdate(previousState?.offset, state.offset, updateClock || hasImmediateChanges);
|
||||
if (updateRuntime) {
|
||||
patch.offset = state.offset;
|
||||
previousState.offset = structuredClone(state.offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* if any values have changed.
|
||||
*/
|
||||
const updateRundownData = !deepEqual(previousState.rundown, state.rundown);
|
||||
if (updateRundownData) {
|
||||
patch.rundown = state.rundown;
|
||||
previousState.rundown = structuredClone(state.rundown);
|
||||
}
|
||||
|
||||
function updateMaybeEntryIfChanged<K extends EntryUpdateKeys>(key: K) {
|
||||
const previousEntry = previousState[key];
|
||||
const currentEntry = state[key];
|
||||
|
||||
if (!previousEntry && !currentEntry) return false; // if both are null -> skip
|
||||
|
||||
// if they have the same id the check if the contents have changed
|
||||
if (previousEntry?.id === currentEntry?.id) {
|
||||
if (deepEqual(previousEntry, currentEntry)) return false; // contents are the same -> skip
|
||||
}
|
||||
// at this point we know that either the id or the contents has changed
|
||||
patch[key] = currentEntry as RuntimeStore[K]; // we know that there is the necessary overlap in the types to cast this
|
||||
previousState[key] = structuredClone(currentEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { patch, hasImmediateChanges };
|
||||
}
|
||||
|
||||
/**
|
||||
* finds the previous playable event, if it exists
|
||||
*/
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`characterisation: playback across midnight > playing across midnight increments currentDay and keeps offsets 1`] = `
|
||||
{
|
||||
"_end": {
|
||||
"accumulatedGap": 0,
|
||||
"event": {
|
||||
"dayOffset": 1,
|
||||
"delay": 0,
|
||||
"duration": 1800000,
|
||||
"gap": 0,
|
||||
"id": "night3",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 3600000,
|
||||
"timeStart": 1800000,
|
||||
"type": "event",
|
||||
},
|
||||
"isLinkedToLoaded": undefined,
|
||||
},
|
||||
"_flag": null,
|
||||
"_group": null,
|
||||
"_rundown": {
|
||||
"totalDelay": 0,
|
||||
},
|
||||
"_startDayOffset": 0,
|
||||
"_startEpoch": 978734100000,
|
||||
"_timer": {
|
||||
"forceFinish": null,
|
||||
"hasFinished": false,
|
||||
"pausedAt": null,
|
||||
"secondaryTarget": null,
|
||||
},
|
||||
"clock": 600000,
|
||||
"eventFlag": null,
|
||||
"eventNext": {
|
||||
"dayOffset": 1,
|
||||
"delay": 0,
|
||||
"duration": 1800000,
|
||||
"gap": 0,
|
||||
"id": "night3",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 3600000,
|
||||
"timeStart": 1800000,
|
||||
"type": "event",
|
||||
},
|
||||
"eventNow": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 3600000,
|
||||
"gap": 0,
|
||||
"id": "night2",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 1800000,
|
||||
"timeStart": 84600000,
|
||||
"type": "event",
|
||||
},
|
||||
"groupNow": null,
|
||||
"offset": {
|
||||
"absolute": 300000,
|
||||
"expectedFlagStart": null,
|
||||
"expectedGroupEnd": null,
|
||||
"expectedRundownEnd": 3900000,
|
||||
"mode": "absolute",
|
||||
"relative": -5400000,
|
||||
},
|
||||
"rundown": {
|
||||
"actualGroupStart": null,
|
||||
"actualStart": 84900000,
|
||||
"currentDay": 1,
|
||||
"numEvents": 3,
|
||||
"plannedEnd": 90000000,
|
||||
"plannedStart": 79200000,
|
||||
"selectedEventIndex": 1,
|
||||
},
|
||||
"timer": {
|
||||
"addedTime": 0,
|
||||
"current": 1500000,
|
||||
"duration": 3600000,
|
||||
"elapsed": 2100000,
|
||||
"expectedFinish": 2100000,
|
||||
"phase": "default",
|
||||
"playback": "play",
|
||||
"secondaryTimer": null,
|
||||
"startedAt": 84900000,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`characterisation: roll mode > roll into overnight event after midnight backdates start metadata 1`] = `
|
||||
{
|
||||
"_end": {
|
||||
"accumulatedGap": 0,
|
||||
"event": {
|
||||
"dayOffset": 1,
|
||||
"delay": 0,
|
||||
"duration": 1800000,
|
||||
"gap": 0,
|
||||
"id": "night3",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 3600000,
|
||||
"timeStart": 1800000,
|
||||
"type": "event",
|
||||
},
|
||||
"isLinkedToLoaded": undefined,
|
||||
},
|
||||
"_flag": null,
|
||||
"_group": null,
|
||||
"_rundown": {
|
||||
"totalDelay": 0,
|
||||
},
|
||||
"_startDayOffset": 0,
|
||||
"_startEpoch": 978733800000,
|
||||
"_timer": {
|
||||
"forceFinish": null,
|
||||
"hasFinished": false,
|
||||
"pausedAt": null,
|
||||
"secondaryTarget": null,
|
||||
},
|
||||
"clock": 300032,
|
||||
"eventFlag": null,
|
||||
"eventNext": {
|
||||
"dayOffset": 1,
|
||||
"delay": 0,
|
||||
"duration": 1800000,
|
||||
"gap": 0,
|
||||
"id": "night3",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 3600000,
|
||||
"timeStart": 1800000,
|
||||
"type": "event",
|
||||
},
|
||||
"eventNow": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 3600000,
|
||||
"gap": 0,
|
||||
"id": "night2",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 1800000,
|
||||
"timeStart": 84600000,
|
||||
"type": "event",
|
||||
},
|
||||
"groupNow": null,
|
||||
"offset": {
|
||||
"absolute": 0,
|
||||
"expectedFlagStart": null,
|
||||
"expectedGroupEnd": null,
|
||||
"expectedRundownEnd": 3600000,
|
||||
"mode": "absolute",
|
||||
"relative": -5400000,
|
||||
},
|
||||
"rundown": {
|
||||
"actualGroupStart": null,
|
||||
"actualStart": 84600000,
|
||||
"currentDay": 1,
|
||||
"numEvents": 3,
|
||||
"plannedEnd": 90000000,
|
||||
"plannedStart": 79200000,
|
||||
"selectedEventIndex": 1,
|
||||
},
|
||||
"timer": {
|
||||
"addedTime": 0,
|
||||
"current": 1499968,
|
||||
"duration": 3600000,
|
||||
"elapsed": 2100032,
|
||||
"expectedFinish": 1800000,
|
||||
"phase": "default",
|
||||
"playback": "roll",
|
||||
"secondaryTimer": null,
|
||||
"startedAt": 84600000,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`characterisation: timed playback > late start: absolute and relative offsets with gap compensation 1`] = `
|
||||
{
|
||||
"_end": {
|
||||
"accumulatedGap": 1200000,
|
||||
"event": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 600000,
|
||||
"gap": 600000,
|
||||
"id": "flat4",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 39600000,
|
||||
"timeStart": 39000000,
|
||||
"type": "event",
|
||||
},
|
||||
"isLinkedToLoaded": undefined,
|
||||
},
|
||||
"_flag": {
|
||||
"accumulatedGap": 0,
|
||||
"event": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 600000,
|
||||
"flag": true,
|
||||
"gap": 0,
|
||||
"id": "flat2",
|
||||
"linkStart": true,
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 37200000,
|
||||
"timeStart": 36600000,
|
||||
"type": "event",
|
||||
},
|
||||
"isLinkedToLoaded": true,
|
||||
},
|
||||
"_group": null,
|
||||
"_rundown": {
|
||||
"totalDelay": 0,
|
||||
},
|
||||
"_startDayOffset": 0,
|
||||
"_startEpoch": 978685500000,
|
||||
"_timer": {
|
||||
"forceFinish": null,
|
||||
"hasFinished": false,
|
||||
"pausedAt": null,
|
||||
"secondaryTarget": null,
|
||||
},
|
||||
"clock": 36300000,
|
||||
"eventFlag": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 600000,
|
||||
"flag": true,
|
||||
"gap": 0,
|
||||
"id": "flat2",
|
||||
"linkStart": true,
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 37200000,
|
||||
"timeStart": 36600000,
|
||||
"type": "event",
|
||||
},
|
||||
"eventNext": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 600000,
|
||||
"flag": true,
|
||||
"gap": 0,
|
||||
"id": "flat2",
|
||||
"linkStart": true,
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeEnd": 37200000,
|
||||
"timeStart": 36600000,
|
||||
"type": "event",
|
||||
},
|
||||
"eventNow": {
|
||||
"dayOffset": 0,
|
||||
"delay": 0,
|
||||
"duration": 600000,
|
||||
"gap": 0,
|
||||
"id": "flat1",
|
||||
"parent": null,
|
||||
"revision": 1,
|
||||
"skip": false,
|
||||
"timeDanger": 60000,
|
||||
"timeEnd": 36600000,
|
||||
"timeStart": 36000000,
|
||||
"timeWarning": 120000,
|
||||
"type": "event",
|
||||
},
|
||||
"groupNow": null,
|
||||
"offset": {
|
||||
"absolute": 300000,
|
||||
"expectedFlagStart": 36900000,
|
||||
"expectedGroupEnd": null,
|
||||
"expectedRundownEnd": 39900000,
|
||||
"mode": "relative",
|
||||
"relative": 0,
|
||||
},
|
||||
"rundown": {
|
||||
"actualGroupStart": null,
|
||||
"actualStart": 36300000,
|
||||
"currentDay": 0,
|
||||
"numEvents": 4,
|
||||
"plannedEnd": 39600000,
|
||||
"plannedStart": 36000000,
|
||||
"selectedEventIndex": 0,
|
||||
},
|
||||
"timer": {
|
||||
"addedTime": 0,
|
||||
"current": 600000,
|
||||
"duration": 600000,
|
||||
"elapsed": 0,
|
||||
"expectedFinish": 36900000,
|
||||
"phase": "default",
|
||||
"playback": "play",
|
||||
"secondaryTimer": null,
|
||||
"startedAt": 36300000,
|
||||
},
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Rundown } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
makeOntimeDelay,
|
||||
makeOntimeEvent,
|
||||
makeOntimeGroup,
|
||||
makeRundown,
|
||||
} from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
|
||||
const h = MILLIS_PER_HOUR;
|
||||
const m = MILLIS_PER_MINUTE;
|
||||
|
||||
export const time = { h, m };
|
||||
|
||||
/**
|
||||
* Flat rundown exercising gaps, linked events and flags
|
||||
* - flat1: 10:00 - 10:10
|
||||
* - flat2: 10:10 - 10:20 (linked to flat1, flagged)
|
||||
* - flat3: 10:30 - 10:40 (10min gap, flagged)
|
||||
* - flat4: 10:50 - 11:00 (10min gap)
|
||||
*
|
||||
* total gap from flat1: 20min, planned 10:00 - 11:00
|
||||
*/
|
||||
export function makeFlatRundown(): Rundown {
|
||||
return makeRundown({
|
||||
entries: {
|
||||
flat1: makeOntimeEvent({
|
||||
id: 'flat1',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
timeWarning: 2 * m,
|
||||
timeDanger: 1 * m,
|
||||
parent: null,
|
||||
}),
|
||||
flat2: makeOntimeEvent({
|
||||
id: 'flat2',
|
||||
timeStart: 10 * h + 10 * m,
|
||||
timeEnd: 10 * h + 20 * m,
|
||||
duration: 10 * m,
|
||||
linkStart: true,
|
||||
flag: true,
|
||||
parent: null,
|
||||
}),
|
||||
flat3: makeOntimeEvent({
|
||||
id: 'flat3',
|
||||
timeStart: 10 * h + 30 * m,
|
||||
timeEnd: 10 * h + 40 * m,
|
||||
duration: 10 * m,
|
||||
flag: true,
|
||||
parent: null,
|
||||
}),
|
||||
flat4: makeOntimeEvent({
|
||||
id: 'flat4',
|
||||
timeStart: 10 * h + 50 * m,
|
||||
timeEnd: 11 * h,
|
||||
duration: 10 * m,
|
||||
parent: null,
|
||||
}),
|
||||
},
|
||||
order: ['flat1', 'flat2', 'flat3', 'flat4'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rundown with a group and a trailing event
|
||||
* - group: [grouped1: 10:00 - 10:30, grouped2: 10:30 - 11:00]
|
||||
* - after: 11:30 - 12:00 (30min gap)
|
||||
*/
|
||||
export function makeGroupedRundown(): Rundown {
|
||||
return makeRundown({
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['grouped1', 'grouped2'] }),
|
||||
grouped1: makeOntimeEvent({
|
||||
id: 'grouped1',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 30 * m,
|
||||
duration: 30 * m,
|
||||
parent: 'group',
|
||||
}),
|
||||
grouped2: makeOntimeEvent({
|
||||
id: 'grouped2',
|
||||
timeStart: 10 * h + 30 * m,
|
||||
timeEnd: 11 * h,
|
||||
duration: 30 * m,
|
||||
parent: 'group',
|
||||
}),
|
||||
after: makeOntimeEvent({
|
||||
id: 'after',
|
||||
timeStart: 11 * h + 30 * m,
|
||||
timeEnd: 12 * h,
|
||||
duration: 30 * m,
|
||||
parent: null,
|
||||
}),
|
||||
},
|
||||
order: ['group', 'after'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Overnight rundown
|
||||
* - night1: 22:00 - 23:30
|
||||
* - night2: 23:30 - 00:30 (crosses midnight)
|
||||
* - night3: 00:30 - 01:00 (next day)
|
||||
*/
|
||||
export function makeOvernightRundown(): Rundown {
|
||||
return makeRundown({
|
||||
entries: {
|
||||
night1: makeOntimeEvent({
|
||||
id: 'night1',
|
||||
timeStart: 22 * h,
|
||||
timeEnd: 23 * h + 30 * m,
|
||||
duration: 90 * m,
|
||||
parent: null,
|
||||
}),
|
||||
night2: makeOntimeEvent({
|
||||
id: 'night2',
|
||||
timeStart: 23 * h + 30 * m,
|
||||
timeEnd: 30 * m,
|
||||
duration: 60 * m,
|
||||
parent: null,
|
||||
}),
|
||||
night3: makeOntimeEvent({
|
||||
id: 'night3',
|
||||
timeStart: 30 * m,
|
||||
timeEnd: 1 * h,
|
||||
duration: 30 * m,
|
||||
parent: null,
|
||||
}),
|
||||
},
|
||||
order: ['night1', 'night2', 'night3'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rundown with a count-to-end event
|
||||
* - lead: 10:00 - 10:30
|
||||
* - toEnd: 10:30 - 11:00 (countToEnd)
|
||||
*/
|
||||
export function makeCountToEndRundown(): Rundown {
|
||||
return makeRundown({
|
||||
entries: {
|
||||
lead: makeOntimeEvent({
|
||||
id: 'lead',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 30 * m,
|
||||
duration: 30 * m,
|
||||
parent: null,
|
||||
}),
|
||||
toEnd: makeOntimeEvent({
|
||||
id: 'toEnd',
|
||||
timeStart: 10 * h + 30 * m,
|
||||
timeEnd: 11 * h,
|
||||
duration: 30 * m,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
}),
|
||||
},
|
||||
order: ['lead', 'toEnd'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rundown with a delay entry
|
||||
* - delayed1: 10:00 - 10:10
|
||||
* - (delay 5min)
|
||||
* - delayed2: 10:20 - 10:30 (10min gap)
|
||||
*/
|
||||
export function makeDelayedRundown(): Rundown {
|
||||
return makeRundown({
|
||||
entries: {
|
||||
delayed1: makeOntimeEvent({
|
||||
id: 'delayed1',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
parent: null,
|
||||
}),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 5 * m, parent: null }),
|
||||
delayed2: makeOntimeEvent({
|
||||
id: 'delayed2',
|
||||
timeStart: 10 * h + 20 * m,
|
||||
timeEnd: 10 * h + 30 * m,
|
||||
duration: 10 * m,
|
||||
parent: null,
|
||||
}),
|
||||
},
|
||||
order: ['delayed1', 'delay', 'delayed2'],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { EntryId, MaybeString, OffsetMode, PlayableEvent, Rundown } from 'ontime-types';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { rundownCache } from '../../../api-data/rundown/rundown.dao.js';
|
||||
import { initRundown } from '../../../api-data/rundown/rundown.service.js';
|
||||
import { RundownMetadata } from '../../../api-data/rundown/rundown.types.js';
|
||||
import { timerConfig } from '../../../setup/config.js';
|
||||
import {
|
||||
type RuntimeState,
|
||||
type UpdateResult,
|
||||
addTime,
|
||||
clearState,
|
||||
getState,
|
||||
load,
|
||||
pause,
|
||||
roll,
|
||||
setOffsetMode,
|
||||
start,
|
||||
stop,
|
||||
update,
|
||||
updateAll,
|
||||
updateLoaded,
|
||||
updateRundownData,
|
||||
} from '../../runtimeState.js';
|
||||
|
||||
/**
|
||||
* A compact, review-friendly projection of the runtime state
|
||||
* This is the primary characterisation contract: it contains every field
|
||||
* whose semantics are consumed by clients (offsets, expected times, playback)
|
||||
* plus the private fields that drive those calculations
|
||||
*/
|
||||
export function digest() {
|
||||
const state = getState();
|
||||
return {
|
||||
clock: state.clock,
|
||||
playback: state.timer.playback,
|
||||
phase: state.timer.phase,
|
||||
current: state.timer.current,
|
||||
duration: state.timer.duration,
|
||||
elapsed: state.timer.elapsed,
|
||||
addedTime: state.timer.addedTime,
|
||||
startedAt: state.timer.startedAt,
|
||||
secondaryTimer: state.timer.secondaryTimer,
|
||||
expectedFinish: state.timer.expectedFinish,
|
||||
absolute: state.offset.absolute,
|
||||
relative: state.offset.relative,
|
||||
mode: state.offset.mode,
|
||||
expectedGroupEnd: state.offset.expectedGroupEnd,
|
||||
expectedFlagStart: state.offset.expectedFlagStart,
|
||||
expectedRundownEnd: state.offset.expectedRundownEnd,
|
||||
plannedStart: state.rundown.plannedStart,
|
||||
plannedEnd: state.rundown.plannedEnd,
|
||||
actualStart: state.rundown.actualStart,
|
||||
actualGroupStart: state.rundown.actualGroupStart,
|
||||
currentDay: state.rundown.currentDay,
|
||||
selectedEventIndex: state.rundown.selectedEventIndex,
|
||||
eventNow: state.eventNow?.id ?? null,
|
||||
eventNext: state.eventNext?.id ?? null,
|
||||
eventFlag: state.eventFlag?.id ?? null,
|
||||
groupNow: state.groupNow?.id ?? null,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
secondaryTarget: state._timer.secondaryTarget,
|
||||
hasFinished: state._timer.hasFinished,
|
||||
startDayOffset: state._startDayOffset,
|
||||
};
|
||||
}
|
||||
|
||||
export type Scenario = {
|
||||
/** the processed rundown, after going through the rundown cache */
|
||||
rundown: Rundown;
|
||||
metadata: RundownMetadata;
|
||||
load: (eventId: EntryId) => boolean;
|
||||
start: () => boolean;
|
||||
pause: () => boolean;
|
||||
stop: () => boolean;
|
||||
addTime: (amount: number) => boolean;
|
||||
/** calls roll, optionally passing the current offset for roll-continuation */
|
||||
roll: (keepOffset?: boolean) => { eventId: MaybeString; didStart: boolean };
|
||||
setOffsetMode: (mode: OffsetMode) => void;
|
||||
updateRundownData: (data: Parameters<typeof updateRundownData>[0]) => void;
|
||||
/**
|
||||
* emulates a rundown edit while playback continues: re-processes the
|
||||
* rundown through the cache and hot-reloads the loaded events, as the
|
||||
* rundown service does on a committed mutation
|
||||
*/
|
||||
hotReload: (newRundown: Rundown) => void;
|
||||
/** re-arms the loaded event, resetting timer progress, as used by reload() */
|
||||
reloadLoaded: () => string | undefined;
|
||||
/**
|
||||
* advances fake time and calls update() in chunks, exactly as EventTimer would
|
||||
* @param ms total time to advance
|
||||
* @param stepMs chunk size, defaults to the production update rate (32ms)
|
||||
* @returns the result of every update call, so tests can assert on finish flags
|
||||
*/
|
||||
tick: (ms?: number, stepMs?: number) => UpdateResult[];
|
||||
/** jumps the wall clock to an absolute time and runs a single update */
|
||||
setTime: (time: string) => UpdateResult;
|
||||
digest: () => ReturnType<typeof digest>;
|
||||
state: () => Readonly<RuntimeState>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a test scenario around the real runtimeState singleton
|
||||
* - initialises the rundown through the production rundown service
|
||||
* - resets playback state and offset mode between scenarios
|
||||
*
|
||||
* Requires vi.useFakeTimers() to be active
|
||||
*/
|
||||
export async function createScenario(initialRundown: Rundown, startTime: string): Promise<Scenario> {
|
||||
vi.setSystemTime(startTime);
|
||||
clearState();
|
||||
// clearState intentionally preserves offset mode, reset it for test isolation
|
||||
setOffsetMode(OffsetMode.Absolute);
|
||||
|
||||
await initRundown(initialRundown, {});
|
||||
// flush the debounced rundown processing
|
||||
vi.runAllTimers();
|
||||
|
||||
const { rundown, metadata } = rundownCache.get();
|
||||
|
||||
const getPlayableEvent = (eventId: EntryId): PlayableEvent => {
|
||||
const event = rundown.entries[eventId];
|
||||
if (event === undefined) {
|
||||
throw new Error(`Test scenario: event ${eventId} not found in rundown`);
|
||||
}
|
||||
return event as PlayableEvent;
|
||||
};
|
||||
|
||||
return {
|
||||
rundown,
|
||||
metadata,
|
||||
load: (eventId) => load(getPlayableEvent(eventId), rundown, metadata),
|
||||
start,
|
||||
pause,
|
||||
stop,
|
||||
addTime,
|
||||
roll: (keepOffset) => roll(rundown, metadata, keepOffset ? getState().offset : undefined),
|
||||
setOffsetMode,
|
||||
updateRundownData,
|
||||
hotReload: (newRundown) => {
|
||||
rundownCache.init(newRundown, {});
|
||||
const { rundown: processedRundown, metadata: processedMetadata } = rundownCache.get();
|
||||
updateAll(processedRundown, processedMetadata);
|
||||
},
|
||||
reloadLoaded: () => updateLoaded(),
|
||||
tick: (ms = timerConfig.updateRate, stepMs = timerConfig.updateRate) => {
|
||||
const results: UpdateResult[] = [];
|
||||
let remaining = ms;
|
||||
while (remaining > 0) {
|
||||
const step = Math.min(stepMs, remaining);
|
||||
vi.advanceTimersByTime(step);
|
||||
results.push(update());
|
||||
remaining -= step;
|
||||
}
|
||||
return results;
|
||||
},
|
||||
setTime: (time) => {
|
||||
vi.setSystemTime(time);
|
||||
return update();
|
||||
},
|
||||
digest,
|
||||
state: getState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to assert on the update results collected during a tick
|
||||
*/
|
||||
export function countFinishes(results: UpdateResult[]) {
|
||||
return {
|
||||
timerFinished: results.filter((result) => result.hasTimerFinished).length,
|
||||
secondaryFinished: results.filter((result) => result.hasSecondaryTimerFinished).length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* Characterisation tests for the runtimeState machinery
|
||||
*
|
||||
* These tests lock down the CURRENT behaviour of the runtime state hot path:
|
||||
* playback transitions, tick updates, absolute and relative offsets,
|
||||
* expected times (rundown end, group end, flag start) and roll mode.
|
||||
*
|
||||
* They drive the real runtimeState singleton through scripted scenarios
|
||||
* with fake timers, exactly as the EventTimer would in production.
|
||||
*
|
||||
* !!! These tests are a behavioural baseline for refactors: if one of these
|
||||
* assertions fails, production behaviour has changed !!!
|
||||
*/
|
||||
import { OffsetMode, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
makeCountToEndRundown,
|
||||
makeDelayedRundown,
|
||||
makeFlatRundown,
|
||||
makeGroupedRundown,
|
||||
makeOvernightRundown,
|
||||
time,
|
||||
} from './harness/scenario.fixtures.js';
|
||||
import { countFinishes, createScenario } from './harness/scenario.utils.js';
|
||||
|
||||
const { h, m } = time;
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('characterisation: loading and rundown data', () => {
|
||||
test('initialising a rundown populates planned times', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:00');
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Stop,
|
||||
plannedStart: 10 * h,
|
||||
plannedEnd: 11 * h,
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
expectedGroupEnd: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('loading an event arms the timer and resolves now/next/flag', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
||||
scenario.load('flat1');
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Armed,
|
||||
eventNow: 'flat1',
|
||||
eventNext: 'flat2',
|
||||
// the loaded event cannot be the flag, so the first flag after flat1 is flat2
|
||||
eventFlag: 'flat2',
|
||||
groupNow: null,
|
||||
current: 10 * m,
|
||||
duration: 10 * m,
|
||||
selectedEventIndex: 0,
|
||||
actualStart: null,
|
||||
currentDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('setOffsetMode while stopped only changes the mode', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
||||
scenario.setOffsetMode(OffsetMode.Relative);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
mode: OffsetMode.Relative,
|
||||
expectedRundownEnd: null,
|
||||
expectedFlagStart: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: timed playback', () => {
|
||||
test('on-time start ticks through phases and finishes exactly once', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
||||
scenario.load('flat1');
|
||||
|
||||
scenario.setTime('jan 5 10:00');
|
||||
expect(scenario.start()).toBe(true);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Play,
|
||||
phase: TimerPhase.Default,
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
currentDay: 0,
|
||||
startDayOffset: 0,
|
||||
current: 10 * m,
|
||||
expectedFinish: 10 * h + 10 * m,
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
// flag (flat2) is linked to the loaded event: it follows the offset directly
|
||||
expectedFlagStart: 10 * h + 10 * m,
|
||||
// rundown ends on schedule
|
||||
expectedRundownEnd: 11 * h,
|
||||
expectedGroupEnd: null,
|
||||
});
|
||||
|
||||
// tick to the warning threshold (2min)
|
||||
let results = scenario.tick(8 * m, 1000);
|
||||
expect(countFinishes(results).timerFinished).toBe(0);
|
||||
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Warning, current: 2 * m });
|
||||
|
||||
// tick to the danger threshold (1min)
|
||||
results = scenario.tick(1 * m, 1000);
|
||||
expect(countFinishes(results).timerFinished).toBe(0);
|
||||
expect(scenario.digest()).toMatchObject({ phase: TimerPhase.Danger, current: 1 * m });
|
||||
|
||||
// tick to the end: the finish flag is raised exactly once, when current <= triggerAhead
|
||||
results = scenario.tick(1 * m, 1000);
|
||||
expect(countFinishes(results).timerFinished).toBe(1);
|
||||
expect(scenario.digest()).toMatchObject({ current: 0, hasFinished: true });
|
||||
|
||||
// continuing into overtime does not re-trigger the finish
|
||||
results = scenario.tick(30 * 1000, 1000);
|
||||
expect(countFinishes(results).timerFinished).toBe(0);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
phase: TimerPhase.Overtime,
|
||||
current: -30 * 1000,
|
||||
// overtime pushes the rundown behind schedule
|
||||
absolute: 30 * 1000,
|
||||
relative: 30 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('late start: absolute and relative offsets with gap compensation', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:59');
|
||||
scenario.load('flat1');
|
||||
|
||||
scenario.setTime('jan 5 10:05');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
startedAt: 10 * h + 5 * m,
|
||||
actualStart: 10 * h + 5 * m,
|
||||
// positive offset: we are 5min behind schedule
|
||||
absolute: 5 * m,
|
||||
// relative to our actual start, we are on time
|
||||
relative: 0,
|
||||
// flat2 is linked to the loaded event, so it follows the offset: 10:10 + 5min
|
||||
expectedFlagStart: 10 * h + 15 * m,
|
||||
// the accumulated gap (20min) is larger than the offset (5min): rundown still ends on schedule
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
|
||||
// switching to relative mode re-anchors the schedule to the actual start
|
||||
scenario.setOffsetMode(OffsetMode.Relative);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
mode: OffsetMode.Relative,
|
||||
absolute: 5 * m,
|
||||
relative: 0,
|
||||
// linked flag: same wall time as in absolute mode
|
||||
expectedFlagStart: 10 * h + 15 * m,
|
||||
// in relative mode the whole schedule shifts by the late start: gaps are kept, not consumed
|
||||
expectedRundownEnd: 11 * h + 5 * m,
|
||||
});
|
||||
|
||||
// full state checkpoint
|
||||
expect(scenario.state()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('early start in relative mode', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:50');
|
||||
scenario.load('flat1');
|
||||
|
||||
scenario.setTime('jan 5 09:56');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
// negative offset: we are 4min ahead of schedule
|
||||
absolute: -4 * m,
|
||||
relative: 0,
|
||||
// linked flag follows the offset
|
||||
expectedFlagStart: 10 * h + 6 * m,
|
||||
// ahead of schedule: unlinked events are still expected at their scheduled time
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
|
||||
scenario.setOffsetMode(OffsetMode.Relative);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
// the schedule shifts 4min earlier
|
||||
expectedFlagStart: 10 * h + 6 * m,
|
||||
expectedRundownEnd: 10 * h + 56 * m,
|
||||
});
|
||||
});
|
||||
|
||||
test('partial gap compensation consumes the gap', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat2');
|
||||
|
||||
// start flat2 15min late (planned 10:10)
|
||||
scenario.setTime('jan 5 10:25');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'flat2',
|
||||
eventNext: 'flat3',
|
||||
// first flag after the loaded event is flat3
|
||||
eventFlag: 'flat3',
|
||||
absolute: 15 * m,
|
||||
// relative to the actual start, flat2 began 10min early
|
||||
// (the rundown started at flat2, skipping flat1's 10min)
|
||||
relative: -10 * m,
|
||||
// flag flat3: gap (10min) only partially compensates the offset (15min)
|
||||
// expected start = scheduled (10:30) + offset (15min) - gap (10min) = 10:35
|
||||
expectedFlagStart: 10 * h + 35 * m,
|
||||
// rundown end: gap (20min) fully compensates the offset (15min)
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('addTime adjusts offsets, can force finish and un-finish', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.start();
|
||||
|
||||
scenario.tick(1 * m, 1000);
|
||||
expect(scenario.digest()).toMatchObject({ current: 9 * m });
|
||||
|
||||
// adding time puts us behind schedule
|
||||
scenario.addTime(2 * m);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
addedTime: 2 * m,
|
||||
current: 11 * m,
|
||||
absolute: 2 * m,
|
||||
relative: 2 * m,
|
||||
expectedFinish: 10 * h + 12 * m,
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
|
||||
// removing more time than the timer has forces a finish
|
||||
scenario.addTime(-12 * m);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
addedTime: -10 * m,
|
||||
current: -1 * m,
|
||||
hasFinished: false,
|
||||
});
|
||||
|
||||
// the finish is reported on the next update
|
||||
let results = scenario.tick();
|
||||
expect(countFinishes(results).timerFinished).toBe(1);
|
||||
expect(scenario.digest()).toMatchObject({ hasFinished: true, phase: TimerPhase.Overtime });
|
||||
|
||||
// !!! characterised quirk: the forced finish flag (_timer.forceFinish) is never
|
||||
// cleared by update(), so every subsequent update keeps reporting a finished timer
|
||||
results = scenario.tick();
|
||||
expect(countFinishes(results).timerFinished).toBe(1);
|
||||
|
||||
// adding time back over zero un-finishes the timer
|
||||
scenario.addTime(5 * m);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
addedTime: -5 * m,
|
||||
hasFinished: false,
|
||||
// net added time: we are now 5min ahead of schedule
|
||||
absolute: -5 * m,
|
||||
});
|
||||
});
|
||||
|
||||
test('pause freezes the timer and accumulates paused time into the offset', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.start();
|
||||
|
||||
scenario.tick(2 * m, 1000);
|
||||
expect(scenario.pause()).toBe(true);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Pause,
|
||||
pausedAt: 10 * h + 2 * m,
|
||||
current: 8 * m,
|
||||
});
|
||||
|
||||
// while paused, the timer freezes but the offset grows with the pause
|
||||
scenario.setTime('jan 5 10:04');
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
current: 8 * m,
|
||||
absolute: 2 * m,
|
||||
relative: 2 * m,
|
||||
expectedFinish: 10 * h + 12 * m,
|
||||
});
|
||||
|
||||
// restarting folds the paused time into addedTime
|
||||
expect(scenario.start()).toBe(true);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Play,
|
||||
pausedAt: null,
|
||||
addedTime: 2 * m,
|
||||
current: 8 * m,
|
||||
absolute: 2 * m,
|
||||
});
|
||||
|
||||
// pause then stop clears everything
|
||||
scenario.pause();
|
||||
expect(scenario.stop()).toBe(true);
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Stop,
|
||||
eventNow: null,
|
||||
current: null,
|
||||
startedAt: null,
|
||||
actualStart: null,
|
||||
absolute: 0,
|
||||
relative: 0,
|
||||
expectedRundownEnd: null,
|
||||
currentDay: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('countToEnd: absolute offset is overtime only', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:35');
|
||||
scenario.load('toEnd');
|
||||
|
||||
// start the count-to-end event 10min late
|
||||
scenario.setTime('jan 5 10:40');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
// !!! characterised: start() does not recompute current, the value
|
||||
// set at load time (11:00 - 10:35) sticks until the next update()
|
||||
current: 25 * m,
|
||||
expectedFinish: 11 * h,
|
||||
// count-to-end absorbs the late start: absolute offset is overtime only
|
||||
absolute: 0,
|
||||
// the relative offset still reflects the raw event start offset
|
||||
relative: -30 * m,
|
||||
});
|
||||
|
||||
// the next update recalculates current against the scheduled end
|
||||
scenario.tick(1000, 1000);
|
||||
expect(scenario.digest()).toMatchObject({ current: 20 * m - 1000 });
|
||||
|
||||
// going into overtime
|
||||
scenario.setTime('jan 5 11:05');
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
current: -5 * m,
|
||||
phase: TimerPhase.Overtime,
|
||||
absolute: 5 * m,
|
||||
relative: -25 * m,
|
||||
expectedFinish: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('group: expectedGroupEnd and actualGroupStart', async () => {
|
||||
const scenario = await createScenario(makeGroupedRundown(), 'jan 5 10:00');
|
||||
scenario.load('grouped1');
|
||||
|
||||
scenario.setTime('jan 5 10:05');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
groupNow: 'group',
|
||||
actualGroupStart: 10 * h + 5 * m,
|
||||
absolute: 5 * m,
|
||||
// group end: grouped2 is not linked and has no gap to absorb the 5min offset
|
||||
// expected start = 10:30 + 5min, group ends 30min later
|
||||
expectedGroupEnd: 11 * h + 5 * m,
|
||||
// rundown end: the 30min gap before 'after' absorbs the offset
|
||||
expectedRundownEnd: 12 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('updateRundownData recomputes expected times while playing', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.setTime('jan 5 10:05');
|
||||
scenario.start();
|
||||
scenario.setOffsetMode(OffsetMode.Relative);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({ expectedRundownEnd: 11 * h + 5 * m });
|
||||
|
||||
// a rundown edit moves the planned start earlier
|
||||
scenario.updateRundownData({
|
||||
numEvents: 4,
|
||||
firstStart: 9 * h + 30 * m,
|
||||
lastEnd: 10 * h + 30 * m,
|
||||
totalDelay: 0,
|
||||
totalDuration: 1 * h,
|
||||
});
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
plannedStart: 9 * h + 30 * m,
|
||||
plannedEnd: 10 * h + 30 * m,
|
||||
// in relative mode the expected end shifts with the (actualStart - plannedStart) delta
|
||||
expectedRundownEnd: 11 * h + 35 * m,
|
||||
});
|
||||
});
|
||||
|
||||
test('updateRundownData while stopped does not produce expected times', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
|
||||
scenario.updateRundownData({
|
||||
numEvents: 4,
|
||||
firstStart: 9 * h + 30 * m,
|
||||
lastEnd: 10 * h + 30 * m,
|
||||
totalDelay: 0,
|
||||
totalDuration: 1 * h,
|
||||
});
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
plannedStart: 9 * h + 30 * m,
|
||||
plannedEnd: 10 * h + 30 * m,
|
||||
expectedRundownEnd: null,
|
||||
absolute: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('delay entries push the expected start of unlinked events', async () => {
|
||||
const scenario = await createScenario(makeDelayedRundown(), 'jan 5 10:00');
|
||||
scenario.load('delayed1');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
absolute: 0,
|
||||
// delayed2 is delayed by 5min: expected start 10:25, ends 10min later
|
||||
expectedRundownEnd: 10 * h + 35 * m,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: hot reload during playback', () => {
|
||||
test('editing the running event recomputes the timer without interrupting playback', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
||||
scenario.load('lead');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
|
||||
// the running event (10:00 - 10:30) is shortened to 10:00 - 10:20
|
||||
const editedRundown = makeCountToEndRundown();
|
||||
// @ts-expect-error -- fixture entries are events
|
||||
editedRundown.entries.lead.timeEnd = 10 * h + 20 * m;
|
||||
// @ts-expect-error -- fixture entries are events
|
||||
editedRundown.entries.lead.duration = 20 * m;
|
||||
scenario.hotReload(editedRundown);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'lead',
|
||||
// playback is not interrupted
|
||||
playback: Playback.Play,
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
// the timer is recomputed against the new duration
|
||||
duration: 20 * m,
|
||||
current: 18 * m,
|
||||
expectedFinish: 10 * h + 20 * m,
|
||||
// expected times are recomputed: the 10min gap before toEnd absorbs
|
||||
// nothing (offset 0), the rundown still ends on schedule
|
||||
expectedRundownEnd: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('removing the running event slides the selection to the event at the same index', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 10:00');
|
||||
scenario.load('lead');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
|
||||
// the running event is deleted from the rundown
|
||||
const editedRundown = makeCountToEndRundown();
|
||||
delete editedRundown.entries.lead;
|
||||
editedRundown.order = ['toEnd'];
|
||||
scenario.hotReload(editedRundown);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
// the event at the previously selected index is loaded in its place
|
||||
eventNow: 'toEnd',
|
||||
eventNext: null,
|
||||
selectedEventIndex: 0,
|
||||
playback: Playback.Play,
|
||||
// !!! characterised: the original start time is kept, the replacement
|
||||
// event plays as if it had started with the removed event
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
duration: 30 * m,
|
||||
// toEnd counts to its scheduled end: 11:00 - 10:02
|
||||
current: 58 * m,
|
||||
expectedFinish: 11 * h,
|
||||
});
|
||||
});
|
||||
|
||||
test('reloading the loaded event re-arms the timer and clears progress', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.start();
|
||||
scenario.tick(2 * m, 1000);
|
||||
scenario.addTime(1 * m);
|
||||
|
||||
const reloadedId = scenario.reloadLoaded();
|
||||
|
||||
expect(reloadedId).toBe('flat1');
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'flat1',
|
||||
playback: Playback.Armed,
|
||||
startedAt: null,
|
||||
addedTime: 0,
|
||||
pausedAt: null,
|
||||
current: 10 * m,
|
||||
duration: 10 * m,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
hasFinished: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: playback across midnight', () => {
|
||||
test('playing across midnight increments currentDay and keeps offsets', async () => {
|
||||
const scenario = await createScenario(makeOvernightRundown(), 'jan 5 23:30');
|
||||
scenario.load('night2');
|
||||
|
||||
scenario.setTime('jan 5 23:35');
|
||||
scenario.start();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
startedAt: 23 * h + 35 * m,
|
||||
actualStart: 23 * h + 35 * m,
|
||||
currentDay: 0,
|
||||
absolute: 5 * m,
|
||||
// relative offset re-anchors to the rundown start (planned 22:00, actual 23:35)
|
||||
relative: -90 * m,
|
||||
// before midnight, next-day events are normalised over 24h:
|
||||
// night3 expected start = 24:30 + 5min offset, ends 30min later
|
||||
expectedRundownEnd: 25 * h + 5 * m,
|
||||
});
|
||||
|
||||
// cross midnight in 1min steps
|
||||
scenario.tick(35 * m, 1 * m);
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
clock: 10 * m,
|
||||
currentDay: 1,
|
||||
// started 23:35 with 60min duration: ends 00:35, 25min left at 00:10
|
||||
current: 25 * m,
|
||||
absolute: 5 * m,
|
||||
relative: -90 * m,
|
||||
// after midnight the same expected end is expressed in today's time
|
||||
expectedRundownEnd: 1 * h + 5 * m,
|
||||
});
|
||||
|
||||
// full state checkpoint
|
||||
expect(scenario.state()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('characterisation: roll mode', () => {
|
||||
test('roll: pending then start on schedule', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 09:55');
|
||||
|
||||
const result = scenario.roll();
|
||||
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Roll,
|
||||
phase: TimerPhase.Pending,
|
||||
secondaryTimer: 5 * m,
|
||||
secondaryTarget: 10 * h,
|
||||
actualStart: null,
|
||||
currentDay: null,
|
||||
});
|
||||
|
||||
// the secondary timer counts down to the event start
|
||||
const results = scenario.tick(5 * m, 1000);
|
||||
expect(countFinishes(results).secondaryFinished).toBe(1);
|
||||
expect(scenario.digest()).toMatchObject({ secondaryTimer: 0 });
|
||||
|
||||
// the runtime service reacts to the secondary timer finishing by calling roll again
|
||||
const startResult = scenario.roll();
|
||||
expect(startResult).toStrictEqual({ eventId: 'flat1', didStart: true });
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Roll,
|
||||
startedAt: 10 * h,
|
||||
actualStart: 10 * h,
|
||||
currentDay: 0,
|
||||
absolute: 0,
|
||||
secondaryTimer: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('roll: takeover from play keeps the running state', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.setTime('jan 5 10:05');
|
||||
scenario.start();
|
||||
|
||||
const result = scenario.roll();
|
||||
expect(result).toStrictEqual({ eventId: 'flat1', didStart: false });
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
playback: Playback.Roll,
|
||||
startedAt: 10 * h + 5 * m,
|
||||
absolute: 5 * m,
|
||||
});
|
||||
});
|
||||
|
||||
test('roll: continuation carries the offset to the next event', async () => {
|
||||
const scenario = await createScenario(makeFlatRundown(), 'jan 5 10:00');
|
||||
scenario.load('flat1');
|
||||
scenario.setTime('jan 5 10:05');
|
||||
scenario.start();
|
||||
scenario.roll();
|
||||
|
||||
// flat1 (started 10:05, 10min duration) is finished at 10:16
|
||||
// the update pushes flat1 1min into overtime, growing the offset to 6min
|
||||
scenario.setTime('jan 5 10:16');
|
||||
expect(scenario.digest()).toMatchObject({ absolute: 6 * m });
|
||||
|
||||
// the runtime service rolls into the next event passing the current offset
|
||||
const result = scenario.roll(true);
|
||||
// with a 6min offset, the offset-clock is 10:10 which is inside flat2 (10:10-10:20)
|
||||
expect(result).toStrictEqual({ eventId: 'flat2', didStart: true });
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'flat2',
|
||||
// start times are backdated to the planned start
|
||||
startedAt: 10 * h + 10 * m,
|
||||
// the offset passed by the service is kept
|
||||
absolute: 6 * m,
|
||||
// !!! characterised: unlike the pending-roll branch, the roll-continuation
|
||||
// branch overwrites actualStart with the new event's planned start
|
||||
actualStart: 10 * h + 10 * m,
|
||||
});
|
||||
});
|
||||
|
||||
test('roll into overnight event after midnight backdates start metadata', async () => {
|
||||
const scenario = await createScenario(makeOvernightRundown(), 'jan 6 00:05');
|
||||
|
||||
const result = scenario.roll();
|
||||
expect(result).toStrictEqual({ eventId: 'night2', didStart: true });
|
||||
|
||||
scenario.tick();
|
||||
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
eventNow: 'night2',
|
||||
// started mid-event: times are backdated to the planned start
|
||||
startedAt: 23 * h + 30 * m,
|
||||
actualStart: 23 * h + 30 * m,
|
||||
startDayOffset: 0,
|
||||
// the backdated epoch is yesterday: we are on day 1
|
||||
currentDay: 1,
|
||||
absolute: 0,
|
||||
// night3 is expected on schedule
|
||||
expectedRundownEnd: 1 * h,
|
||||
});
|
||||
|
||||
// full state checkpoint
|
||||
expect(scenario.state()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('roll: pending across midnight renormalises the secondary target', async () => {
|
||||
const scenario = await createScenario(makeCountToEndRundown(), 'jan 5 23:50');
|
||||
|
||||
// all events are in the past: roll pends for tomorrow's first event (10:00)
|
||||
const result = scenario.roll();
|
||||
expect(result).toStrictEqual({ eventId: 'lead', didStart: false });
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
phase: TimerPhase.Pending,
|
||||
// 10h10min until tomorrow 10:00, target normalised over 24h
|
||||
secondaryTimer: 10 * h + 10 * m,
|
||||
secondaryTarget: dayInMs + 10 * h,
|
||||
currentDay: null,
|
||||
});
|
||||
|
||||
// crossing midnight renormalises the target to today's time
|
||||
scenario.setTime('jan 6 00:10');
|
||||
expect(scenario.digest()).toMatchObject({
|
||||
secondaryTimer: 9 * h + 50 * m,
|
||||
secondaryTarget: 10 * h,
|
||||
currentDay: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Direct tests for the parameterized runtimeState cores
|
||||
*
|
||||
* These run the calculation logic against plain state objects: no module
|
||||
* mocks, no singleton, no rundown cache. They document the arithmetic of
|
||||
* the tick, the added-time edge cases and the expected-times projection.
|
||||
*/
|
||||
import { OffsetMode, OntimeGroup, PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import { makeRuntimeStateData } from '../__mocks__/runtimeState.mocks.js';
|
||||
import { type RuntimeState, addTimeCore, getExpectedTimesCore, updateCore } from '../runtimeState.js';
|
||||
|
||||
const h = MILLIS_PER_HOUR;
|
||||
const m = MILLIS_PER_MINUTE;
|
||||
|
||||
/** a 10min event, planned 10:00 - 10:10 */
|
||||
function makeRunningEvent(): PlayableEvent {
|
||||
return makeOntimeEvent({
|
||||
id: 'running',
|
||||
timeStart: 10 * h,
|
||||
timeEnd: 10 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
gap: 0,
|
||||
countToEnd: false,
|
||||
}) as PlayableEvent;
|
||||
}
|
||||
|
||||
/** state as it would be after starting the running event on time at 10:00 */
|
||||
function makePlayingState(): RuntimeState {
|
||||
return makeRuntimeStateData({
|
||||
eventNow: makeRunningEvent(),
|
||||
timer: {
|
||||
playback: Playback.Play,
|
||||
duration: 10 * m,
|
||||
current: 10 * m,
|
||||
elapsed: 0,
|
||||
startedAt: 10 * h,
|
||||
expectedFinish: 10 * h + 10 * m,
|
||||
},
|
||||
rundown: {
|
||||
selectedEventIndex: 0,
|
||||
numEvents: 1,
|
||||
plannedStart: 10 * h,
|
||||
plannedEnd: 10 * h + 10 * m,
|
||||
actualStart: 10 * h,
|
||||
currentDay: 0,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_startEpoch: new Date('2026-01-05T10:00:00').getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
describe('updateCore()', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('recomputes the timer against the wall clock', () => {
|
||||
const state = makePlayingState();
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
||||
const result = updateCore(state);
|
||||
|
||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
||||
expect(state.timer.current).toBe(6 * m);
|
||||
expect(state.timer.elapsed).toBe(4 * m);
|
||||
expect(state.timer.expectedFinish).toBe(10 * h + 10 * m);
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
});
|
||||
|
||||
test('the finish triggers once, when current enters the trigger-ahead window (10ms)', () => {
|
||||
const state = makePlayingState();
|
||||
|
||||
// 11ms left on the timer: not finished yet
|
||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.989'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
||||
expect(state._timer.hasFinished).toBe(false);
|
||||
|
||||
// 10ms left: the finish is triggered and latched
|
||||
vi.setSystemTime(new Date('2026-01-05T10:09:59.990'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
||||
expect(state._timer.hasFinished).toBe(true);
|
||||
|
||||
// subsequent updates do not re-trigger, the timer runs into overtime
|
||||
vi.setSystemTime(new Date('2026-01-05T10:10:30'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(false);
|
||||
expect(state.timer.current).toBe(-30_000);
|
||||
expect(state.timer.phase).toBe(TimerPhase.Overtime);
|
||||
// overtime pushes the rundown behind schedule
|
||||
expect(state.offset.absolute).toBe(30_000);
|
||||
});
|
||||
|
||||
test('a pending forceFinish reports the timer as finished', () => {
|
||||
const state = makePlayingState();
|
||||
state._timer.forceFinish = 10 * h + 2 * m;
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:02:00'));
|
||||
expect(updateCore(state).hasTimerFinished).toBe(true);
|
||||
expect(state._timer.hasFinished).toBe(true);
|
||||
});
|
||||
|
||||
test('does nothing but update the clock when playback is idle', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-05T10:04:00'));
|
||||
const result = updateCore(state);
|
||||
|
||||
expect(result).toStrictEqual({ hasTimerFinished: false, hasSecondaryTimerFinished: false });
|
||||
expect(state.clock).toBe(10 * h + 4 * m);
|
||||
expect(state.timer.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTimeCore()', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-05T10:05:00'));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('added time moves the offset and the expected finish', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = 5 * m; // 10:05, halfway through
|
||||
|
||||
expect(addTimeCore(state, 2 * m)).toBe(true);
|
||||
|
||||
expect(state.timer.addedTime).toBe(2 * m);
|
||||
expect(state.timer.current).toBe(7 * m);
|
||||
// positive offset: we are now 2min behind schedule
|
||||
expect(state.offset.absolute).toBe(2 * m);
|
||||
expect(state.timer.expectedFinish).toBe(10 * h + 12 * m);
|
||||
});
|
||||
|
||||
test('removing more time than remains forces a finish', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = 5 * m;
|
||||
|
||||
addTimeCore(state, -8 * m);
|
||||
|
||||
// the force flag is raised for the next update to report
|
||||
expect(state._timer.forceFinish).not.toBeNull();
|
||||
expect(state.timer.current).toBe(-3 * m);
|
||||
// 3min overtime minus 8min removed: 5min ahead of schedule
|
||||
expect(state.offset.absolute).toBe(-5 * m);
|
||||
});
|
||||
|
||||
test('adding time back over zero un-finishes the timer', () => {
|
||||
const state = makePlayingState();
|
||||
state.timer.current = -1 * m;
|
||||
state.timer.addedTime = -6 * m;
|
||||
state._timer.hasFinished = true;
|
||||
|
||||
addTimeCore(state, 5 * m);
|
||||
|
||||
expect(state.timer.current).toBe(4 * m);
|
||||
expect(state._timer.hasFinished).toBe(false);
|
||||
});
|
||||
|
||||
test('refuses when there is no timer', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
expect(addTimeCore(state, 1 * m)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedTimesCore()', () => {
|
||||
/** a downstream 10min event, planned 11:00 - 11:10 */
|
||||
function makeDownstreamEvent(patch?: Record<string, unknown>) {
|
||||
return makeOntimeEvent({
|
||||
id: 'downstream',
|
||||
timeStart: 11 * h,
|
||||
timeEnd: 11 * h + 10 * m,
|
||||
duration: 10 * m,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* playing state with a downstream rundown end
|
||||
* @param offset current absolute offset (positive = behind schedule)
|
||||
* @param accumulatedGap total gap between the running and the downstream event
|
||||
* @param isLinkedToLoaded whether the downstream event links back to the running one
|
||||
*/
|
||||
function makeStateWithEnd(args: {
|
||||
offset: number;
|
||||
accumulatedGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
mode?: OffsetMode;
|
||||
actualStart?: number;
|
||||
downstream?: ReturnType<typeof makeOntimeEvent>;
|
||||
}): RuntimeState {
|
||||
const state = makePlayingState();
|
||||
state.offset.absolute = args.offset;
|
||||
state.offset.mode = args.mode ?? OffsetMode.Absolute;
|
||||
if (args.actualStart !== undefined) state.rundown.actualStart = args.actualStart;
|
||||
state._end = {
|
||||
event: args.downstream ?? makeDownstreamEvent(),
|
||||
accumulatedGap: args.accumulatedGap,
|
||||
isLinkedToLoaded: args.isLinkedToLoaded,
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
test('on schedule: the rundown ends at the planned time', () => {
|
||||
const state = makeStateWithEnd({ offset: 0, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// 11:00 + 10min duration
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: a larger gap absorbs the whole offset', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// gap (20min) > offset (5min): the downstream event still starts as scheduled
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: a smaller gap absorbs part of the offset', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 2 * m, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 11:00 + 5min offset - 2min gap = 11:03, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 13 * m);
|
||||
});
|
||||
|
||||
test('behind schedule: linked events follow the offset, gaps do not apply', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 20 * m, isLinkedToLoaded: true });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 11:00 + 5min offset = 11:05, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('ahead of schedule: unlinked events wait for their scheduled time', () => {
|
||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
getExpectedTimesCore(state);
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('ahead of schedule: linked events pull in with the offset', () => {
|
||||
const state = makeStateWithEnd({ offset: -4 * m, accumulatedGap: 0, isLinkedToLoaded: true });
|
||||
getExpectedTimesCore(state);
|
||||
// expected start 10:56, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 6 * m);
|
||||
});
|
||||
|
||||
test('relative mode: the schedule is re-anchored to the actual start', () => {
|
||||
// started 5min late (actual 10:05, planned 10:00) with no accrued drift
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 20 * m,
|
||||
isLinkedToLoaded: false,
|
||||
mode: OffsetMode.Relative,
|
||||
actualStart: 10 * h + 5 * m,
|
||||
});
|
||||
state.offset.relative = 0;
|
||||
getExpectedTimesCore(state);
|
||||
// the whole schedule shifts by the 5min late start: 11:05 + 10min
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('delays push the expected start', () => {
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 10 * m,
|
||||
isLinkedToLoaded: false,
|
||||
downstream: makeDownstreamEvent({ delay: 5 * m }),
|
||||
});
|
||||
getExpectedTimesCore(state);
|
||||
// delayed start 11:05, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('events on a later day are normalised over 24h', () => {
|
||||
const state = makeStateWithEnd({
|
||||
offset: 0,
|
||||
accumulatedGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
downstream: makeDownstreamEvent({ dayOffset: 1 }),
|
||||
});
|
||||
getExpectedTimesCore(state);
|
||||
// tomorrow 11:00 relative to today: 24h + 11:00, ends 10min later
|
||||
expect(state.offset.expectedRundownEnd).toBe(dayInMs + 11 * h + 10 * m);
|
||||
});
|
||||
|
||||
test('flag start and group end use the same projection', () => {
|
||||
const state = makeStateWithEnd({ offset: 5 * m, accumulatedGap: 0, isLinkedToLoaded: false });
|
||||
const downstream = makeDownstreamEvent() as PlayableEvent;
|
||||
|
||||
state.eventFlag = downstream;
|
||||
state._flag = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
||||
state.groupNow = makeOntimeGroup({ id: 'group' }) as OntimeGroup;
|
||||
state._group = { event: downstream, accumulatedGap: 0, isLinkedToLoaded: false };
|
||||
|
||||
getExpectedTimesCore(state);
|
||||
|
||||
// no gap to absorb the 5min offset: expected start 11:05
|
||||
expect(state.offset.expectedFlagStart).toBe(11 * h + 5 * m);
|
||||
// the group ends when its last event finishes: 11:05 + 10min
|
||||
expect(state.offset.expectedGroupEnd).toBe(11 * h + 15 * m);
|
||||
expect(state.offset.expectedRundownEnd).toBe(11 * h + 15 * m);
|
||||
});
|
||||
|
||||
test('without a loaded event there are no expected times', () => {
|
||||
const state = makeRuntimeStateData();
|
||||
getExpectedTimesCore(state);
|
||||
|
||||
expect(state.offset.expectedRundownEnd).toBeNull();
|
||||
expect(state.offset.expectedFlagStart).toBeNull();
|
||||
expect(state.offset.expectedGroupEnd).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
clearState,
|
||||
getState,
|
||||
load,
|
||||
loadGroupFlagAndEnd,
|
||||
loadGroupFlagAndEndCore,
|
||||
pause,
|
||||
resume,
|
||||
roll,
|
||||
start,
|
||||
startCore,
|
||||
stop,
|
||||
update,
|
||||
} from '../runtimeState.js';
|
||||
@@ -82,7 +83,7 @@ describe('mutation on runtimeState', () => {
|
||||
await initRundown(makeRundown({}), {});
|
||||
vi.runAllTimers();
|
||||
|
||||
let success = start(mockState);
|
||||
let success = startCore(mockState);
|
||||
expect(success).toBe(false);
|
||||
|
||||
success = pause();
|
||||
@@ -868,7 +869,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '3'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 2, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 2);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: rundown.entries[1],
|
||||
@@ -896,7 +897,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: rundown.entries[2],
|
||||
@@ -924,7 +925,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '11', '22'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 1, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 1);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: null,
|
||||
@@ -949,7 +950,7 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1'], flags: ['1'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
loadGroupFlagAndEndCore(state, rundown, metadata, 0);
|
||||
|
||||
expect(state).toMatchObject({
|
||||
groupNow: null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,8 @@ test('cuesheet datagrid does not submit timer cells on tab-out or escape', async
|
||||
|
||||
// re-enter edit mode: original value should be unchanged
|
||||
await durationCell.click();
|
||||
// tabbing selects the next input field so we have to click twice to first leave input field and then select
|
||||
await durationCell.click();
|
||||
await expect(durationCell.locator('input')).toHaveValue(originalDuration);
|
||||
await durationCell.locator('input').press('Escape');
|
||||
|
||||
@@ -100,3 +102,27 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
||||
await expect(cueEditor).not.toBeFocused();
|
||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||
});
|
||||
|
||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
// create an empty rundown
|
||||
await page.goto('/editor');
|
||||
await page.getByRole('button', { name: 'Toggle settings' }).click();
|
||||
await page.getByRole('button', { name: 'Manage rundowns' }).click();
|
||||
await page.getByRole('button', { name: 'New' }).nth(1).click();
|
||||
const emptyName = `empty-${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
||||
|
||||
// edit it in the cuesheet
|
||||
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
||||
await page.getByText('Edit in cuesheet').click();
|
||||
|
||||
// expect to see and empty screen
|
||||
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
|
||||
|
||||
// create 1 event
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
|
||||
// and expect to find it
|
||||
await expect(page.getByTestId('cuesheet-event')).toBeVisible();
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -55,7 +55,7 @@
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "22.22.2"
|
||||
"version": "22.22.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.9.0",
|
||||
"version": "4.10.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -20,6 +20,7 @@ type BaseURLPreset = {
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
search: string;
|
||||
displayInNav: boolean;
|
||||
options?: Record<string, string>;
|
||||
};
|
||||
|
||||
@@ -28,6 +29,7 @@ type CuesheetUrlPreset = {
|
||||
enabled: boolean;
|
||||
alias: string;
|
||||
search: string;
|
||||
displayInNav: boolean;
|
||||
options?: {
|
||||
read?: string;
|
||||
write?: string;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const langEn = {
|
||||
'timeline.done': 'done',
|
||||
'timeline.due': 'due',
|
||||
'timeline.followedby': 'Followed by',
|
||||
'timeline.standby': 'Standby',
|
||||
'project.title': 'Title',
|
||||
'project.description': 'Description',
|
||||
'project.info': 'Project Info',
|
||||
|
||||
Generated
+522
-278
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user