mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 972a246bb6 | |||
| f39892dc6e | |||
| 6bcfd87851 | |||
| 4addb50ca4 | |||
| 161ab3fe13 | |||
| 4f4626a163 | |||
| 82d5f02ea0 | |||
| 05aaeddf22 | |||
| 949ab2b7dc | |||
| f12d3ca59c |
+1
-1
@@ -23,7 +23,7 @@
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*", "zod"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
||||
import { memo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { hasCustomParams, useSavedViewParams } from '../../stores/savedViewParams';
|
||||
import { useViewParamsEditorStore } from '../view-params-editor/viewParamsEditor.store';
|
||||
import FloatingNavigation from './floating-navigation/FloatingNavigation';
|
||||
import NavigationMenu from './NavigationMenu';
|
||||
@@ -17,6 +19,9 @@ export default memo(ViewNavigationMenu);
|
||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||
const [searchParams] = useSearchParams();
|
||||
const savedParams = useSavedViewParams((store) => store.params);
|
||||
const hasSavedChanges = hasCustomParams(searchParams) || Object.keys(savedParams).length > 0;
|
||||
|
||||
useHotkeys([
|
||||
[
|
||||
@@ -46,6 +51,7 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
||||
<FloatingNavigation
|
||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||
hasSavedChanges={hasSavedChanges}
|
||||
/>
|
||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
||||
</>
|
||||
|
||||
+26
-1
@@ -1,4 +1,29 @@
|
||||
.linkIcon {
|
||||
.trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.linkIcon {
|
||||
@include rotate-fourty-five;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear {
|
||||
opacity: 0.6;
|
||||
transition: opacity $transition-time-action;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IoArrowUp } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { MouseEvent, PropsWithChildren } from 'react';
|
||||
import { IoArrowUp, IoCloseOutline } from 'react-icons/io5';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { useElectronEvent } from '../../../hooks/useElectronEvent';
|
||||
import {
|
||||
hasCustomParams,
|
||||
reservedParams,
|
||||
stripReservedParams,
|
||||
useSavedViewParams,
|
||||
} from '../../../stores/savedViewParams';
|
||||
import { handleLinks } from '../../../utils/linkUtils';
|
||||
import IconButton from '../../buttons/IconButton';
|
||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||
|
||||
import style from './ClientLink.module.scss';
|
||||
@@ -16,32 +23,100 @@ interface ClientLinkProps {
|
||||
|
||||
export default function ClientLink({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||
const { isElectron } = useElectronEvent();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isElectron) {
|
||||
return (
|
||||
<NavigationMenuItem
|
||||
active={current}
|
||||
onClick={() => {
|
||||
handleLinks(to);
|
||||
postAction?.();
|
||||
}}
|
||||
>
|
||||
<ElectronNavigationItem current={current} to={to} postAction={postAction}>
|
||||
{children}
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</NavigationMenuItem>
|
||||
</ElectronNavigationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuItem
|
||||
active={current}
|
||||
onClick={() => {
|
||||
navigate(`/${to}`);
|
||||
postAction?.();
|
||||
}}
|
||||
>
|
||||
<BrowserNavigationItem current={current} to={to} postAction={postAction}>
|
||||
{children}
|
||||
</BrowserNavigationItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ElectronNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||
const navigateToLink = () => {
|
||||
handleLinks(to);
|
||||
postAction?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationMenuItem active={current} onClick={navigateToLink}>
|
||||
{children}
|
||||
<span className={style.trailing}>
|
||||
<IoArrowUp className={style.linkIcon} />
|
||||
</span>
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function BrowserNavigationItem({ current, to, postAction, children }: PropsWithChildren<ClientLinkProps>) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { params: savedParams, save, clear } = useSavedViewParams();
|
||||
|
||||
/**
|
||||
* Save the params of the view we are leaving and resolve the destination,
|
||||
* restoring any params previously saved for the target view.
|
||||
*/
|
||||
const resolveDestination = () => {
|
||||
const currentView = location.pathname.replace(/^\//, '');
|
||||
save(currentView, stripReservedParams(location.search));
|
||||
const restored = savedParams[to];
|
||||
return `${to}${restored ? `?${restored}` : ''}`;
|
||||
};
|
||||
|
||||
const isCustomised = current ? hasCustomParams(searchParams) : Boolean(savedParams[to]);
|
||||
|
||||
const navigateToLink = () => {
|
||||
const destination = resolveDestination();
|
||||
navigate(`/${destination}`);
|
||||
postAction?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the saved settings for this route without navigating to it.
|
||||
* When it is the current view, also reset the live URL to its defaults.
|
||||
*/
|
||||
const clearViewSettings = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
clear(to);
|
||||
|
||||
// if mounted, clear the URL params
|
||||
if (current) {
|
||||
const preserved = new URLSearchParams();
|
||||
reservedParams.forEach((key) => {
|
||||
const value = searchParams.get(key);
|
||||
if (value !== null) preserved.set(key, value);
|
||||
});
|
||||
setSearchParams(preserved);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationMenuItem active={current} onClick={navigateToLink}>
|
||||
{children}
|
||||
{isCustomised && (
|
||||
<span className={style.trailing}>
|
||||
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Clear saved view settings'
|
||||
title='Clear saved view settings'
|
||||
onClick={clearViewSettings}
|
||||
>
|
||||
<IoCloseOutline />
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
+16
@@ -22,3 +22,19 @@
|
||||
top: 0;
|
||||
z-index: $zindex-nav;
|
||||
}
|
||||
|
||||
.buttonWithIndicator {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: -0.15rem;
|
||||
right: -0.15rem;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
+14
-10
@@ -9,9 +9,10 @@ import style from './FloatingNavigation.module.scss';
|
||||
interface FloatingNavigationProps {
|
||||
toggleMenu?: () => void;
|
||||
toggleSettings?: () => void;
|
||||
hasSavedChanges?: boolean;
|
||||
}
|
||||
|
||||
export default function FloatingNavigation({ toggleMenu, toggleSettings }: FloatingNavigationProps) {
|
||||
export default function FloatingNavigation({ toggleMenu, toggleSettings, hasSavedChanges }: FloatingNavigationProps) {
|
||||
const isButtonShown = useFadeOutOnInactivity(true);
|
||||
|
||||
return (
|
||||
@@ -20,15 +21,18 @@ export default function FloatingNavigation({ toggleMenu, toggleSettings }: Float
|
||||
className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}
|
||||
>
|
||||
{toggleMenu && (
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
<div className={style.buttonWithIndicator}>
|
||||
<IconButton
|
||||
variant='subtle-white'
|
||||
size='xlarge'
|
||||
onClick={toggleMenu}
|
||||
aria-label='toggle menu'
|
||||
data-testid='navigation__toggle-menu'
|
||||
>
|
||||
<IoApps />
|
||||
</IconButton>
|
||||
{hasSavedChanges && <span className={style.indicator} data-testid='navigation__saved-indicator' />}
|
||||
</div>
|
||||
)}
|
||||
{toggleSettings && (
|
||||
<IconButton
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
color: $white-10;
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
display: block;
|
||||
width: min(100%, 24rem);
|
||||
margin-inline: auto;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@@ -13,6 +15,6 @@
|
||||
margin-inline: auto;
|
||||
font-weight: 600;
|
||||
font-size: 2em;
|
||||
max-width: 600px;
|
||||
max-width: min(100%, 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { hasCustomParams, stripReservedParams, useSavedViewParams } from '../savedViewParams';
|
||||
|
||||
describe('savedViewParams store', () => {
|
||||
beforeEach(() => {
|
||||
useSavedViewParams.getState().clearAll();
|
||||
});
|
||||
|
||||
it('saves and restores params per view', () => {
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||
|
||||
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=true');
|
||||
expect(useSavedViewParams.getState().params.backstage).toBe('showProgress=false');
|
||||
});
|
||||
|
||||
it('overwrites the saved params for a view on subsequent saves', () => {
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=false');
|
||||
|
||||
expect(useSavedViewParams.getState().params.timer).toBe('hideSeconds=false');
|
||||
});
|
||||
|
||||
it('does not store an entry for an empty search string', () => {
|
||||
useSavedViewParams.getState().save('timer', '');
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({});
|
||||
});
|
||||
|
||||
it('removes a previously saved entry when saved with an empty search string', () => {
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||
useSavedViewParams.getState().save('timer', '');
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores empty view keys', () => {
|
||||
useSavedViewParams.getState().save('', 'hideSeconds=true');
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({});
|
||||
});
|
||||
|
||||
it('clears the saved params for a single view without touching others', () => {
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||
useSavedViewParams.getState().clear('timer');
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
|
||||
});
|
||||
|
||||
it('is a no-op when clearing a view without saved params', () => {
|
||||
useSavedViewParams.getState().save('backstage', 'showProgress=false');
|
||||
useSavedViewParams.getState().clear('timer');
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({ backstage: 'showProgress=false' });
|
||||
});
|
||||
|
||||
it('clears all saved params', () => {
|
||||
useSavedViewParams.getState().save('timer', 'hideSeconds=true');
|
||||
useSavedViewParams.getState().clearAll();
|
||||
|
||||
expect(useSavedViewParams.getState().params).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripReservedParams', () => {
|
||||
it('removes reserved auth/preset params while keeping view customisation', () => {
|
||||
expect(stripReservedParams('hideSeconds=true&token=abc&n=1&alias=my')).toBe('hideSeconds=true');
|
||||
});
|
||||
|
||||
it('returns an empty string when only reserved params are present', () => {
|
||||
expect(stripReservedParams('token=abc&n=1&alias=my')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCustomParams', () => {
|
||||
it('is true when a non-reserved param is present', () => {
|
||||
expect(hasCustomParams(new URLSearchParams('hideSeconds=true&token=abc'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when only reserved params are present', () => {
|
||||
expect(hasCustomParams(new URLSearchParams('token=abc&n=1&alias=my'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when there are no params', () => {
|
||||
expect(hasCustomParams(new URLSearchParams(''))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
// params that are auth/preset markers, not user view customisation (see common/utils/urlPresets.ts)
|
||||
export const reservedParams = new Set(['token', 'n', 'alias']);
|
||||
|
||||
interface SavedViewParamsStore {
|
||||
params: Record<string, string>; // view key (e.g. "timer") -> search string without leading "?"
|
||||
save: (view: string, search: string) => void;
|
||||
clear: (view: string) => void;
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remembers the last view parameters used for each view so they can be
|
||||
* restored when the user navigates back to that view through the menu.
|
||||
* In-memory only: persists across SPA navigation, resets on a full page reload.
|
||||
*/
|
||||
export const useSavedViewParams = create<SavedViewParamsStore>((set) => ({
|
||||
params: {},
|
||||
save: (view, search) =>
|
||||
set((state) => {
|
||||
// ignore empty view keys and do not store empty entries, so the
|
||||
// "saved changes" indicator only reflects genuine customisation
|
||||
if (!view) return state;
|
||||
const params = { ...state.params };
|
||||
if (search) {
|
||||
params[view] = search;
|
||||
} else {
|
||||
delete params[view];
|
||||
}
|
||||
return { params };
|
||||
}),
|
||||
clear: (view) =>
|
||||
set((state) => {
|
||||
if (!state.params[view]) return state;
|
||||
const params = { ...state.params };
|
||||
delete params[view];
|
||||
return { params };
|
||||
}),
|
||||
clearAll: () => set({ params: {} }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Removes reserved (auth/preset) params from a search string, keeping only
|
||||
* genuine view customisation.
|
||||
*/
|
||||
export function stripReservedParams(search: string): string {
|
||||
const sp = new URLSearchParams(search);
|
||||
reservedParams.forEach((key) => sp.delete(key));
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the search params contain any genuine view customisation,
|
||||
* ignoring reserved params.
|
||||
*/
|
||||
export function hasCustomParams(searchParams: URLSearchParams): boolean {
|
||||
for (const key of searchParams.keys()) {
|
||||
if (!reservedParams.has(key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { linkToOtherHost } from '../linkUtils';
|
||||
import { hostToBaseUrl, linkToOtherHost } from '../linkUtils';
|
||||
|
||||
describe('linkToOTherHost', () => {
|
||||
it('should handle electron links', () => {
|
||||
@@ -22,3 +22,18 @@ describe('linkToOTherHost', () => {
|
||||
expect(destination).toBe('https://app.getontime.no/user-hash/path');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostToBaseUrl', () => {
|
||||
it('uses http when the page is plain http', () => {
|
||||
expect(hostToBaseUrl('192.168.10.166', 4001, 'http://localhost:4001')).toBe('http://192.168.10.166:4001');
|
||||
});
|
||||
|
||||
it('keeps https when the page reaches the server over TLS on the same port', () => {
|
||||
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://localhost:4001')).toBe('https://192.168.10.166:4001');
|
||||
});
|
||||
|
||||
it('falls back to http when the page is https on a different port (TLS proxy)', () => {
|
||||
// the raw server port does not speak TLS, an https link to it would fail
|
||||
expect(hostToBaseUrl('192.168.10.166', 4001, 'https://ontime.example.com')).toBe('http://192.168.10.166:4001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,12 @@ export function handleLinks(
|
||||
event?.preventDefault();
|
||||
|
||||
const destination = new URL(externalServerUrl);
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
|
||||
// split off any query string so it is not encoded into the pathname
|
||||
const [pathname, search] = location.split('?');
|
||||
destination.pathname = externalBaseURI ? `${externalBaseURI}/${pathname}` : pathname;
|
||||
if (search) {
|
||||
destination.search = search;
|
||||
}
|
||||
openLink(destination.toString());
|
||||
}
|
||||
|
||||
@@ -54,3 +59,17 @@ export function linkToOtherHost(
|
||||
}
|
||||
return destination.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a base URL for reaching the Ontime server on a given network interface.
|
||||
* The Ontime server itself only speaks HTTP, so we only keep https when the page
|
||||
* already reaches the server over TLS on that same port (eg. a proxy in front of it);
|
||||
* any other explicit server port gets http.
|
||||
* externalServerUrl is used for testing
|
||||
*/
|
||||
export function hostToBaseUrl(host: string, port: number, externalServerUrl: string = serverURL): string {
|
||||
const page = new URL(externalServerUrl);
|
||||
const pagePort = page.port === '' ? (page.protocol === 'https:' ? 443 : 80) : Number(page.port);
|
||||
const scheme = page.protocol === 'https:' && pagePort === port ? 'https' : 'http';
|
||||
return `${scheme}://${host}:${port}`;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
|
||||
.section {
|
||||
position: relative;
|
||||
margin-top: 2rem;
|
||||
max-width: 850px;
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
.indent {
|
||||
|
||||
@@ -2,33 +2,23 @@ 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 { 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;
|
||||
})();
|
||||
|
||||
// the page origin is reachable wherever the user is browsing from,
|
||||
// and follows the deployment (cloud, reverse proxy, port mappings)
|
||||
// we are reusing the endpoint, so locking config and nav have no effect
|
||||
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||
generateUrl({ baseUrl: serverURL, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||
.then(setMcpEndpointUrl)
|
||||
.catch(() => {
|
||||
setMcpEndpointUrl('');
|
||||
});
|
||||
}, [infoData]);
|
||||
}, []);
|
||||
|
||||
const mcpClientConfig = mcpEndpointUrl
|
||||
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
|
||||
@@ -40,14 +30,19 @@ export default function McpSection() {
|
||||
<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.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Client configuration snippet'
|
||||
description='Paste this into your AI agent settings under "mcpServers"'
|
||||
/>
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -82,6 +82,17 @@
|
||||
margin-right: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.over {
|
||||
color: $playback-over;
|
||||
.strike {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoLockClosed,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
@@ -176,14 +177,17 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>Duration</div>
|
||||
{planOffset === null ? (
|
||||
<div>{formatDuration(data.duration)}</div>
|
||||
) : (
|
||||
<div className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</div>
|
||||
)}
|
||||
<div className={style.duration}>
|
||||
{planOffset === null ? (
|
||||
formatDuration(data.duration)
|
||||
) : (
|
||||
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import useInfo from '../../common/hooks-query/useInfo';
|
||||
import useUrlPresets from '../../common/hooks-query/useUrlPresets';
|
||||
import { hostToBaseUrl } from '../../common/utils/linkUtils';
|
||||
import GenerateLinkForm from './GenerateLinkForm';
|
||||
|
||||
interface GenerateLinkFormExportProps {
|
||||
@@ -21,7 +22,7 @@ export default function GenerateLinkFormExport({ lockedPath }: GenerateLinkFormE
|
||||
*/
|
||||
const hostOptions = useMemo(() => {
|
||||
return infoData.networkInterfaces.map((nif) => ({
|
||||
value: `http://${nif.address}:${infoData.serverPort}`,
|
||||
value: hostToBaseUrl(nif.address, infoData.serverPort),
|
||||
label: `${nif.name} - ${nif.address}`,
|
||||
}));
|
||||
}, [infoData.networkInterfaces, infoData.serverPort]);
|
||||
|
||||
@@ -18,6 +18,7 @@ export const langDe: TranslationObject = {
|
||||
'common.no_data': 'Keine Daten',
|
||||
'countdown.ended': 'Veranstaltung endete um',
|
||||
'countdown.running': 'Veranstaltung läuft',
|
||||
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
|
||||
'countdown.loaded': 'Veranstaltung geladen',
|
||||
'countdown.select_event': 'Wählen Sie eine Veranstaltung aus, um sie zu verfolgen',
|
||||
'countdown.to_start': 'Zeit bis zum Start',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const langEs: TranslationObject = {
|
||||
'common.no_data': 'Sin datos',
|
||||
'countdown.ended': 'Evento finalizado a las',
|
||||
'countdown.running': 'Evento en curso',
|
||||
'countdown.group_running': 'Evento en grupo en curso',
|
||||
'countdown.loaded': 'Evento está cargado', //TODO: check translation
|
||||
'countdown.select_event': 'Seleccionar un evento para seguir',
|
||||
'countdown.to_start': 'Tiempo para comenzar',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const langFr: TranslationObject = {
|
||||
'common.no_data': 'Aucune donnée',
|
||||
'countdown.ended': 'Évènement terminé à',
|
||||
'countdown.running': 'Évènement en cours',
|
||||
'countdown.group_running': 'Évènement du groupe en cours',
|
||||
'countdown.loaded': 'Évènement chargé',
|
||||
'countdown.select_event': 'Sélectionnez un évènement à suivre',
|
||||
'countdown.to_start': 'Évènement commence dans',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const langIt: TranslationObject = {
|
||||
'common.no_data': 'Nessun dato disponibile',
|
||||
'countdown.ended': 'Evento finito alle',
|
||||
'countdown.running': 'Evento in corso',
|
||||
'countdown.group_running': 'Evento nel gruppo in corso',
|
||||
'countdown.loaded': 'Evento caricato', //TODO: check translation
|
||||
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||
'countdown.to_start': 'Tempo alla partenza',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const langPt: TranslationObject = {
|
||||
'common.no_data': 'Sem dados',
|
||||
'countdown.ended': 'Evento encerrado às',
|
||||
'countdown.running': 'Evento em andamento',
|
||||
'countdown.group_running': 'Evento em grupo em andamento',
|
||||
'countdown.loaded': 'Evento carregado', //TODO: check translation
|
||||
'countdown.select_event': 'Selecione um evento para acompanhar',
|
||||
'countdown.to_start': 'Tempo para iniciar',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -140,25 +141,23 @@ export const ScheduleProvider = ({ children, selectedEventId }: PropsWithChildre
|
||||
return () => clearInterval(paginator.current);
|
||||
}, [cycleInterval, numPages, stopCycle, visiblePage]);
|
||||
|
||||
let selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
|
||||
// we want to show the event after the current
|
||||
const viewEvents = events.slice(selectedEventIndex + 1);
|
||||
selectedEventIndex = 0;
|
||||
const viewEvents = useMemo(() => {
|
||||
const selectedEventIndex = events.findIndex((event) => event.id === selectedEventId);
|
||||
return (events as ExtendedEntry<OntimeEvent>[]).slice(selectedEventIndex + 1);
|
||||
}, [events, selectedEventId]);
|
||||
|
||||
return (
|
||||
<ScheduleContext
|
||||
value={{
|
||||
events: viewEvents as ExtendedEntry<OntimeEvent>[],
|
||||
selectedEventId,
|
||||
numPages,
|
||||
visiblePage,
|
||||
containerRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScheduleContext>
|
||||
);
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
events: viewEvents,
|
||||
selectedEventId,
|
||||
numPages,
|
||||
visiblePage,
|
||||
containerRef,
|
||||
};
|
||||
}, [viewEvents, selectedEventId, numPages, visiblePage, containerRef]);
|
||||
|
||||
return <ScheduleContext value={value}>{children}</ScheduleContext>;
|
||||
};
|
||||
|
||||
export const useSchedule = () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ $item-height: 3.5rem;
|
||||
right: $view-inline-padding;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 2rem;
|
||||
|
||||
@@ -72,14 +73,27 @@ $item-height: 3.5rem;
|
||||
}
|
||||
|
||||
/* ========================= LIST ========================*/
|
||||
.empty-container {
|
||||
.empty-state {
|
||||
min-height: min(56vh, 36rem);
|
||||
padding-top: clamp(4rem, 14vh, 9rem);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
margin-top: 2rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state__content {
|
||||
max-width: none;
|
||||
|
||||
span {
|
||||
max-width: none;
|
||||
white-space: nowrap;
|
||||
font-size: clamp(1.5rem, 4vw, 2.25rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +102,7 @@ $item-height: 3.5rem;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
padding-bottom: 60vh;
|
||||
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
||||
}
|
||||
|
||||
/* ====================== LIST-ITEM ======================*/
|
||||
@@ -104,6 +118,7 @@ $item-height: 3.5rem;
|
||||
column-gap: 1rem;
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding-right: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
&:hover {
|
||||
@@ -115,18 +130,41 @@ $item-height: 3.5rem;
|
||||
|
||||
.sub--selected {
|
||||
background: $blue-700;
|
||||
box-shadow: inset 0 0 0 1px $blue-500;
|
||||
}
|
||||
|
||||
.sub--live {
|
||||
background-color: $active-green;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, $active-green 75%, $ui-white);
|
||||
}
|
||||
|
||||
.sub--armed {
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
.sub--in-group {
|
||||
position: relative;
|
||||
margin-left: 1rem;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -0.5rem;
|
||||
top: -0.25rem;
|
||||
bottom: -0.25rem;
|
||||
width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--group-color, transparent);
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
box-shadow: inset 3px 0 0 var(--group-color, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.sub__binder {
|
||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
border-radius: $element-border-radius 0 0 $element-border-radius;
|
||||
grid-area: binder;
|
||||
}
|
||||
|
||||
@@ -138,6 +176,8 @@ $item-height: 3.5rem;
|
||||
gap: 0.25em;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sub__schedule--delayed {
|
||||
@@ -156,22 +196,73 @@ $item-height: 3.5rem;
|
||||
color: $playback-under;
|
||||
}
|
||||
|
||||
.sub--group {
|
||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||
var(--card-background-color-override, $viewer-card-bg-color);
|
||||
|
||||
.sub__binder {
|
||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sub__schedule,
|
||||
.sub__status,
|
||||
.sub__label {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.sub--group.sub--selected {
|
||||
box-shadow: inset 0 0 0 1px $blue-500;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 22%, transparent), transparent 42%),
|
||||
$blue-700;
|
||||
|
||||
.sub__schedule,
|
||||
.sub__status,
|
||||
.sub__label,
|
||||
.sub__eyebrow {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.sub__title {
|
||||
grid-area: title;
|
||||
padding-bottom: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sub__eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 0.15rem;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
font-size: $timer-label-size;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sub__secondary {
|
||||
grid-area: secondary;
|
||||
padding-bottom: 0.5rem;
|
||||
min-width: 0;
|
||||
|
||||
font-size: $base-font-size;
|
||||
line-height: 1.1em;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
// allow multi-line text but trim before
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sub__status {
|
||||
@@ -181,6 +272,7 @@ $item-height: 3.5rem;
|
||||
font-size: $timer-label-size;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub__label {
|
||||
@@ -190,6 +282,7 @@ $item-height: 3.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub__timer {
|
||||
@@ -199,6 +292,16 @@ $item-height: 3.5rem;
|
||||
line-height: 1.1em;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
font-size: $timer-label-size;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ====================== MODIFIERS ======================*/
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { EntryId, OntimeEvent, OntimeView, PlayableEvent, isOntimeEvent, isPlayableEvent } from 'ontime-types';
|
||||
import {
|
||||
EntryId,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeView,
|
||||
PlayableEvent,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
@@ -15,7 +25,7 @@ import { useTranslation } from '../../translation/TranslationProvider';
|
||||
import Loader from '../common/loader/Loader';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
|
||||
import { getOrderedSubscriptions } from './countdown.utils';
|
||||
import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils';
|
||||
import CountdownSelect from './CountdownSelect';
|
||||
import CountdownSubscriptions from './CountdownSubscriptions';
|
||||
import SingleEventCountdown from './SingleEventCountdown';
|
||||
@@ -45,13 +55,19 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// gather rundown data
|
||||
const playableEvents = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent> => {
|
||||
return isOntimeEvent(entry) && isPlayableEvent(entry);
|
||||
// gather subscribable items: playable events and groups that contain at least one playable child
|
||||
const candidates = rundownData.filter((entry): entry is ExtendedEntry<PlayableEvent | OntimeGroup> => {
|
||||
if (isOntimeEvent(entry)) {
|
||||
return isPlayableEvent(entry);
|
||||
}
|
||||
if (isOntimeGroup(entry)) {
|
||||
return rundownData.some((item) => isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// gather presentation data
|
||||
const hasEvents = playableEvents.length > 0;
|
||||
const hasEvents = candidates.length > 0;
|
||||
|
||||
// gather option data
|
||||
const defaultFormat = getDefaultFormat(settings?.timeFormat);
|
||||
@@ -69,15 +85,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
||||
<CountdownClock />
|
||||
</div>
|
||||
|
||||
{!hasEvents && <Empty text={getLocalizedString('common.no_data')} className='empty-container' />}
|
||||
{!hasEvents && (
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEvents && editMode && (
|
||||
<CountdownSelect events={playableEvents} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
|
||||
<CountdownSelect events={candidates} subscriptions={subscriptions} disableEdit={() => setEditMode(false)} />
|
||||
)}
|
||||
|
||||
{hasEvents && !editMode && (
|
||||
<CountdownContents
|
||||
playableEvents={playableEvents}
|
||||
candidates={candidates}
|
||||
rundownData={rundownData}
|
||||
subscriptions={subscriptions}
|
||||
goToEditMode={() => setEditMode(true)}
|
||||
/>
|
||||
@@ -87,19 +108,20 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
|
||||
}
|
||||
|
||||
interface CountdownContentsProps {
|
||||
playableEvents: ExtendedEntry<OntimeEvent>[];
|
||||
candidates: ExtendedEntry<OntimeEvent | OntimeGroup>[];
|
||||
rundownData: ExtendedEntry<OntimeEntry>[];
|
||||
subscriptions: EntryId[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
function CountdownContents({ playableEvents, subscriptions, goToEditMode }: CountdownContentsProps) {
|
||||
function CountdownContents({ candidates, rundownData, subscriptions, goToEditMode }: CountdownContentsProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const { hidePast } = useCountdownOptions();
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
return (
|
||||
<div className='empty-container'>
|
||||
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||
<IoAdd /> Add
|
||||
</Button>
|
||||
@@ -107,13 +129,15 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
||||
);
|
||||
}
|
||||
|
||||
const subscribedEvents = getOrderedSubscriptions(subscriptions, playableEvents);
|
||||
const subscribedEvents = getOrderedSubscriptions(subscriptions, candidates)
|
||||
.map((entry) => resolveSubscriptionTarget(entry, rundownData))
|
||||
.filter((target): target is NonNullable<typeof target> => target !== null);
|
||||
const eventsToShow = !hidePast ? subscribedEvents : subscribedEvents.filter((event) => !event.isPast);
|
||||
|
||||
if (subscribedEvents.length === 0) {
|
||||
return (
|
||||
<div className='empty-container'>
|
||||
<Empty text={getLocalizedString('countdown.select_event')} className='empty-container' />
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
|
||||
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
|
||||
<IoAdd /> Add
|
||||
</Button>
|
||||
@@ -129,8 +153,8 @@ function CountdownContents({ playableEvents, subscriptions, goToEditMode }: Coun
|
||||
|
||||
if (eventsToShow.length === 0) {
|
||||
return (
|
||||
<div className='empty-container'>
|
||||
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-container' />
|
||||
<div className='empty-state'>
|
||||
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EntryId, PlayableEvent } from 'ontime-types';
|
||||
import { EntryId, OntimeEvent, OntimeGroup, isOntimeGroup } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
import { IoArrowBack, IoClose, IoSaveOutline } from 'react-icons/io5';
|
||||
import { useNavigate } from 'react-router';
|
||||
@@ -12,7 +12,7 @@ import { makeSubscriptionsUrl } from './countdown.utils';
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSelectProps {
|
||||
events: ExtendedEntry<PlayableEvent>[];
|
||||
events: ExtendedEntry<OntimeEvent | OntimeGroup>[];
|
||||
subscriptions: EntryId[];
|
||||
disableEdit: () => void;
|
||||
}
|
||||
@@ -52,13 +52,15 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
|
||||
|
||||
return (
|
||||
<div className='list-container'>
|
||||
{events.map((event, index) => {
|
||||
{events.map((event) => {
|
||||
const title = event.title || '{no title}';
|
||||
const isSelected = selectedIds.has(event.id);
|
||||
const isGroup = isOntimeGroup(event);
|
||||
const isGroupedEvent = !isGroup && Boolean(event.parent);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={event.id}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => toggleSelect(event.id)}
|
||||
@@ -68,21 +70,33 @@ export default function CountdownSelect({ events, subscriptions, disableEdit }:
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className={cx(['sub', isSelected && 'sub--selected'])}
|
||||
className={cx([
|
||||
'sub',
|
||||
isSelected && 'sub--selected',
|
||||
isGroup && 'sub--group',
|
||||
isGroupedEvent && 'sub--in-group',
|
||||
])}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event?.colour ?? '' }} />
|
||||
<div
|
||||
className='sub__binder'
|
||||
style={{ '--user-color': event?.colour ?? '', '--group-color': event.groupColour ?? 'transparent' }}
|
||||
/>
|
||||
<div className='sub__schedule'>
|
||||
<ClockTime value={event.timeStart} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
|
||||
→
|
||||
<ClockTime value={event.timeEnd} preferredFormat12='h:mm a' preferredFormat24='HH:mm' />
|
||||
</div>
|
||||
<div className='sub__label'>{isSelected ? 'Click to remove' : 'Click to add'}</div>
|
||||
<div className='sub__title'>{title}</div>
|
||||
<div className='sub__title'>
|
||||
{isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className='fab-container'>
|
||||
<div className='selection-count'>{selected.length} selected</div>
|
||||
<Button variant='subtle' size='xlarge' onClick={disableEdit}>
|
||||
<IoArrowBack /> Go back
|
||||
</Button>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getPropertyValue } from '../common/viewUtils';
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import {
|
||||
CountdownEvent,
|
||||
CountdownTarget,
|
||||
extendEventData,
|
||||
getIsLive,
|
||||
isOutsideRange,
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
import './Countdown.scss';
|
||||
|
||||
interface CountdownSubscriptionsProps {
|
||||
subscribedEvents: ExtendedEntry<OntimeEvent>[];
|
||||
subscribedEvents: CountdownTarget[];
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -98,22 +99,39 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
||||
return (
|
||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||
{subscribedEvents.map((event) => {
|
||||
const secondaryData = getPropertyValue(event, secondarySource);
|
||||
const isLive = getIsLive(event.id, selectedEventId, playback);
|
||||
const isArmed = !isLive && event.id === selectedEventId;
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
||||
// a subscribed group is live when any of its children is the selected/running event
|
||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
||||
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
|
||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
ref={isLive ? selectedRef : undefined}
|
||||
className={cx(['sub', isLive && 'sub--live', isArmed && 'sub--armed'])}
|
||||
className={cx([
|
||||
'sub',
|
||||
isLive && 'sub--live',
|
||||
isArmed && 'sub--armed',
|
||||
event.isGroup && 'sub--group',
|
||||
isGroupedEvent && 'sub--in-group',
|
||||
])}
|
||||
data-testid={event.cue}
|
||||
>
|
||||
<div className='sub__binder' style={{ '--user-color': event.colour }} />
|
||||
<div
|
||||
className='sub__binder'
|
||||
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||
/>
|
||||
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||
<SubscriptionStatus event={countdownEvent} />
|
||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>{displayTitle}</div>
|
||||
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||
{displayTitle}
|
||||
</div>
|
||||
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,13 +11,13 @@ import { cx } from '../../common/utils/styleUtils';
|
||||
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
import { useCountdownOptions } from './countdown.options';
|
||||
import { useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
|
||||
interface SingleEventCountdownProps {
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent>;
|
||||
subscribedEvent: CountdownTarget;
|
||||
goToEditMode: () => void;
|
||||
}
|
||||
|
||||
@@ -38,11 +38,13 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
mode,
|
||||
});
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.id] ?? { endedAt: null };
|
||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
|
||||
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
|
||||
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
|
||||
const secondaryData = getPropertyValue(subscribedEvent, secondarySource);
|
||||
// while a group is live, surface the running event's title as the secondary line
|
||||
const liveTitle = subscribedEvent.isGroup && subscribedEvent.liveEntry ? subscribedEvent.liveEntry.title : undefined;
|
||||
const secondaryData = liveTitle ?? getPropertyValue(subscribedEvent, secondarySource);
|
||||
|
||||
return (
|
||||
<div className='single-container' data-testid='countdown-event'>
|
||||
|
||||
@@ -80,7 +80,7 @@ export const getCountdownOptions = (
|
||||
{
|
||||
id: 'sub',
|
||||
title: 'Event subscription',
|
||||
description: 'The events to follow',
|
||||
description: 'The events and groups to follow',
|
||||
values: persistedSubscriptions,
|
||||
type: 'persist',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||
|
||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||
import { resolveSubscriptionTarget } from './countdown.utils';
|
||||
|
||||
/**
|
||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||
* Only the fields exercised by resolveSubscriptionTarget are provided; the rest are cast away.
|
||||
*/
|
||||
function makeEvent(patch: Partial<ExtendedEntry<OntimeEvent>>): ExtendedEntry<OntimeEvent> {
|
||||
return {
|
||||
id: 'event',
|
||||
type: SupportedEntry.Event,
|
||||
title: 'event title',
|
||||
cue: '1',
|
||||
colour: '',
|
||||
skip: false,
|
||||
parent: null,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
delay: 0,
|
||||
dayOffset: 0,
|
||||
gap: 0,
|
||||
countToEnd: false,
|
||||
custom: {},
|
||||
note: '',
|
||||
// metadata
|
||||
totalGap: 0,
|
||||
isLinkedToLoaded: false,
|
||||
isLoaded: false,
|
||||
isPast: false,
|
||||
...patch,
|
||||
} as ExtendedEntry<OntimeEvent>;
|
||||
}
|
||||
|
||||
function makeGroup(patch: Partial<ExtendedEntry<OntimeGroup>>): ExtendedEntry<OntimeGroup> {
|
||||
return {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
title: 'group title',
|
||||
colour: '#abcdef',
|
||||
note: 'group note',
|
||||
entries: [],
|
||||
duration: 0,
|
||||
custom: {},
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
isPast: false,
|
||||
isLoaded: false,
|
||||
...patch,
|
||||
} as ExtendedEntry<OntimeGroup>;
|
||||
}
|
||||
|
||||
describe('resolveSubscriptionTarget()', () => {
|
||||
it('returns events unchanged', () => {
|
||||
const event = makeEvent({ id: 'e1' });
|
||||
const result = resolveSubscriptionTarget(event, [event]);
|
||||
expect(result).toBe(event);
|
||||
});
|
||||
|
||||
it('derives group timing from the first playable child while keeping the group identity', () => {
|
||||
const group = makeGroup({ id: 'g1', title: 'Session A', colour: '#123456', duration: 5000 });
|
||||
const child1 = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1000, delay: 100, dayOffset: 0, title: 'Pres 1' });
|
||||
const child2 = makeEvent({ id: 'c2', parent: 'g1', timeStart: 3000, title: 'Pres 2' });
|
||||
const flat: ExtendedEntry<OntimeEntry>[] = [group, child1, child2];
|
||||
|
||||
const result = resolveSubscriptionTarget(group, flat);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
// group identity and display
|
||||
expect(result?.id).toBe('g1');
|
||||
expect(result?.title).toBe('Session A');
|
||||
expect(result?.colour).toBe('#123456');
|
||||
expect(result?.isGroup).toBe(true);
|
||||
// timing comes from the first child, duration from the group
|
||||
expect(result?.timeStart).toBe(1000);
|
||||
expect(result?.delay).toBe(100);
|
||||
expect(result?.duration).toBe(5000);
|
||||
expect(result?.countToEnd).toBe(false);
|
||||
// state checks target the first child before any child is loaded
|
||||
expect(result?.targetId).toBe('c1');
|
||||
// report lookup targets the last child (session end)
|
||||
expect(result?.reportId).toBe('c2');
|
||||
});
|
||||
|
||||
it('returns null for a group with no playable children', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const result = resolveSubscriptionTarget(group, [group]);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('skips skipped children when picking the first child', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const skipped = {
|
||||
...makeEvent({ id: 'c0', parent: 'g1', timeStart: 500 }),
|
||||
skip: true,
|
||||
} as ExtendedEntry<OntimeEntry>;
|
||||
const playable = makeEvent({ id: 'c1', parent: 'g1', timeStart: 1500 });
|
||||
const result = resolveSubscriptionTarget(group, [group, skipped, playable]);
|
||||
expect(result?.timeStart).toBe(1500);
|
||||
expect(result?.reportId).toBe('c1');
|
||||
});
|
||||
|
||||
it('is live while any child is loaded and not past', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
|
||||
const child2 = makeEvent({ id: 'c2', parent: 'g1', isLoaded: true });
|
||||
const child3 = makeEvent({ id: 'c3', parent: 'g1' });
|
||||
const result = resolveSubscriptionTarget(group, [group, child1, child2, child3]);
|
||||
|
||||
expect(result?.isLoaded).toBe(true);
|
||||
expect(result?.isPast).toBe(false);
|
||||
expect(result?.liveEntry?.id).toBe('c2');
|
||||
});
|
||||
|
||||
it('is past only once the last child is past and nothing is loaded', () => {
|
||||
const group = makeGroup({ id: 'g1' });
|
||||
const child1 = makeEvent({ id: 'c1', parent: 'g1', isPast: true });
|
||||
const child2 = makeEvent({ id: 'c2', parent: 'g1', isPast: true });
|
||||
const result = resolveSubscriptionTarget(group, [group, child1, child2]);
|
||||
|
||||
expect(result?.isLoaded).toBe(false);
|
||||
expect(result?.isPast).toBe(true);
|
||||
expect(result?.targetId).toBe('c1');
|
||||
expect(result?.liveEntry).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,17 @@
|
||||
import { EntryId, MaybeNumber, OffsetMode, OntimeEntry, OntimeEvent, OntimeReport, Playback } from 'ontime-types';
|
||||
import {
|
||||
EntryId,
|
||||
MaybeNumber,
|
||||
OffsetMode,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeReport,
|
||||
Playback,
|
||||
PlayableEvent,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
|
||||
|
||||
import { useCountdownSocket } from '../../common/hooks/useSocket';
|
||||
@@ -40,7 +53,7 @@ export const timerProgress: TimerMessage = {
|
||||
* Handles events in different days but disregards whether an event has actually played
|
||||
*/
|
||||
export function useSubscriptionDisplayData(
|
||||
subscribedEvent: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number },
|
||||
subscribedEvent: CountdownTarget & { endedAt: MaybeNumber; expectedStart: number },
|
||||
): { status: ProgressStatus; statusDisplay: string; timeDisplay: string } {
|
||||
const { playback, current, clock } = useCountdownSocket();
|
||||
const { getLocalizedString } = useTranslation();
|
||||
@@ -67,7 +80,11 @@ export function useSubscriptionDisplayData(
|
||||
|
||||
return {
|
||||
status: 'live',
|
||||
statusDisplay: getLocalizedString(timerProgress['live']),
|
||||
statusDisplay: getLocalizedString(
|
||||
subscribedEvent.parent || (subscribedEvent.isGroup && subscribedEvent.liveEntry)
|
||||
? 'countdown.group_running'
|
||||
: timerProgress['live'],
|
||||
),
|
||||
timeDisplay: bigDuration(current ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -168,10 +185,75 @@ export function isOutsideRange(a: number, b: number): boolean {
|
||||
return Math.abs(a - b) > MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
export type CountdownEvent = ExtendedEntry<OntimeEvent> & { expectedStart: number; endedAt: MaybeNumber };
|
||||
/**
|
||||
* A subscription target normalised to the event-shaped object the countdown display consumes.
|
||||
* For events this is a pass-through; for groups it carries the group identity and display data
|
||||
* while deriving timing from the group's first playable child (see resolveSubscriptionTarget).
|
||||
*/
|
||||
export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
|
||||
isGroup?: boolean;
|
||||
targetId?: EntryId; // child entry used for countdown/live/armed state checks
|
||||
reportId?: EntryId; // entry used for the report lookup (e.g. last child of a group)
|
||||
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
|
||||
};
|
||||
|
||||
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
|
||||
|
||||
/**
|
||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||
* - Events are returned unchanged.
|
||||
* - Groups count down to their first playable child while displaying the group identity.
|
||||
* Status is group-aware: live while any child is loaded, past only once the whole group has finished.
|
||||
* Returns null when a group has no playable children (nothing to count down to).
|
||||
*/
|
||||
export function resolveSubscriptionTarget(
|
||||
entry: ExtendedEntry<OntimeEvent | OntimeGroup>,
|
||||
flatRundown: ExtendedEntry<OntimeEntry>[],
|
||||
): CountdownTarget | null {
|
||||
if (!isOntimeGroup(entry)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const children = flatRundown.filter(
|
||||
(item): item is ExtendedEntry<PlayableEvent> =>
|
||||
isOntimeEvent(item) && isPlayableEvent(item) && item.parent === entry.id,
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstChild = children[0];
|
||||
const lastChild = children[children.length - 1];
|
||||
const liveEntry = children.find((child) => child.isLoaded) ?? null;
|
||||
const isLoaded = liveEntry !== null;
|
||||
const isPast = !isLoaded && lastChild.isPast;
|
||||
|
||||
return {
|
||||
// timing derives from the first playable child
|
||||
...firstChild,
|
||||
// group identity and display data
|
||||
id: entry.id,
|
||||
parent: null,
|
||||
title: entry.title,
|
||||
colour: entry.colour,
|
||||
note: entry.note,
|
||||
custom: entry.custom,
|
||||
duration: entry.duration,
|
||||
countToEnd: false,
|
||||
// group-aware status
|
||||
isLoaded,
|
||||
isPast,
|
||||
// group markers
|
||||
isGroup: true,
|
||||
targetId: firstChild.id,
|
||||
reportId: lastChild.id,
|
||||
liveEntry,
|
||||
};
|
||||
}
|
||||
|
||||
export function extendEventData(
|
||||
event: ExtendedEntry<OntimeEvent>,
|
||||
event: CountdownTarget,
|
||||
currentDay: number,
|
||||
actualStart: MaybeNumber,
|
||||
plannedStart: MaybeNumber,
|
||||
@@ -189,6 +271,6 @@ export function extendEventData(
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
const { endedAt } = reportData[event.id] ?? { endedAt: null };
|
||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt };
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { parseField } from '../cuesheet.utils';
|
||||
|
||||
describe('parseField()', () => {
|
||||
it('returns a string from given millis on timeStart, TimeEnd and duration', () => {
|
||||
const testData1 = 1000;
|
||||
const testData2 = 60000;
|
||||
const testData3 = 600000;
|
||||
expect(parseField('timeStart', testData1)).toBe('00:00:01');
|
||||
expect(parseField('timeEnd', testData2)).toBe('00:01:00');
|
||||
expect(parseField('duration', testData3)).toBe('00:10:00');
|
||||
});
|
||||
|
||||
it('returns an empty string on undefined fields', () => {
|
||||
// @ts-expect-error -- testing user data with missing fields
|
||||
expect(parseField('title')).toBe('');
|
||||
});
|
||||
|
||||
describe('simply returns any other value in any other field', () => {
|
||||
const testFields = [
|
||||
{ field: 'nothing', value: '123' },
|
||||
{ field: 'title', value: 'test' },
|
||||
{ field: 'note', value: 'test' },
|
||||
{ field: 'colour', value: 'test' },
|
||||
];
|
||||
|
||||
testFields.forEach((testCase) => {
|
||||
test(`${testCase.field}:${testCase.value}`, () => {
|
||||
expect(parseField(testCase.field, testCase.value)).toBe(testCase.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
import { CustomFields, MaybeNumber, OntimeEntryCommonKeys } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
type CsvHeaderKey = OntimeEntryCommonKeys | keyof CustomFields;
|
||||
|
||||
/**
|
||||
* @description parses a field for export
|
||||
* @param {string} field
|
||||
* @param {*} data
|
||||
* @return {string}
|
||||
*/
|
||||
|
||||
export const parseField = (field: CsvHeaderKey, data: unknown): string => {
|
||||
if (field === 'timeStart' || field === 'timeEnd' || field === 'duration') {
|
||||
return millisToString(data as MaybeNumber, { fallback: '' });
|
||||
}
|
||||
|
||||
if (field === 'skip') {
|
||||
return data ? 'x' : '';
|
||||
}
|
||||
|
||||
return String(data ?? '');
|
||||
};
|
||||
@@ -71,6 +71,11 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
configure: logProxyRequests,
|
||||
},
|
||||
'^/mcp': {
|
||||
target: 'http://localhost:4001/',
|
||||
changeOrigin: true,
|
||||
configure: logProxyRequests,
|
||||
},
|
||||
'^/ws*': {
|
||||
target: 'http://localhost:4001/',
|
||||
changeOrigin: true,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"devDependencies": {
|
||||
"ontime-types": "workspace:^4.2.1",
|
||||
"tsdown": "^0.21.5",
|
||||
"tsdown": "^0.22.4",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "6.0"
|
||||
"isolatedDeclarations": true,
|
||||
"allowJs": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"]
|
||||
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||
import { validateGenerateUrl } from '../session.validation.js';
|
||||
|
||||
describe('validateGenerateUrl', () => {
|
||||
it('accepts a valid payload and normalises req.body', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: true,
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body).toMatchObject({ baseUrl: 'https://ontime.example', path: '/timer' });
|
||||
});
|
||||
|
||||
it('accepts an optional preset field', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: true,
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
preset: 'my-preset',
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body.preset).toBe('my-preset');
|
||||
});
|
||||
|
||||
it('rejects a missing required field with a 422', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||
body: { path: '/timer', authenticate: true, lockConfig: false, lockNav: false },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects a wrong-typed field', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: 'yes', // should be boolean
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types'
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import * as sessionService from './session.service.js';
|
||||
import type { GenerateUrlInput } from './session.validation.js';
|
||||
import { validateGenerateUrl } from './session.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -28,17 +29,21 @@ router.get('/info', (_req: Request, res: Response<GetInfo | ErrorResponse>) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
|
||||
try {
|
||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||
authenticate: req.body.authenticate,
|
||||
lockConfig: req.body.lockConfig,
|
||||
lockNav: req.body.lockNav,
|
||||
preset: req.body.preset,
|
||||
});
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
'/url',
|
||||
validateGenerateUrl,
|
||||
(req: Request<unknown, GetUrl | ErrorResponse, GenerateUrlInput>, res: Response<GetUrl | ErrorResponse>) => {
|
||||
try {
|
||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||
authenticate: req.body.authenticate,
|
||||
lockConfig: req.body.lockConfig,
|
||||
lockNav: req.body.lockNav,
|
||||
preset: req.body.preset,
|
||||
});
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { body } from 'express-validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { validateBody } from '../validation-utils/validate.js';
|
||||
|
||||
export const validateGenerateUrl = [
|
||||
body('baseUrl').isString().trim().notEmpty(),
|
||||
body('path').isString().trim().notEmpty(),
|
||||
|
||||
body('authenticate').isBoolean(),
|
||||
body('lockConfig').isBoolean(),
|
||||
body('lockNav').isBoolean(),
|
||||
body('preset').optional().isString().trim().notEmpty(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
const generateUrlSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1),
|
||||
path: z.string().trim().min(1),
|
||||
authenticate: z.boolean(),
|
||||
lockConfig: z.boolean(),
|
||||
lockNav: z.boolean(),
|
||||
preset: z.string().trim().min(1).optional(),
|
||||
});
|
||||
export type GenerateUrlInput = z.infer<typeof generateUrlSchema>;
|
||||
export const validateGenerateUrl = validateBody(generateUrlSchema);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||
import { validateNewPreset, validatePresetParam } from '../urlPresets.validation.js';
|
||||
|
||||
const validPreset = {
|
||||
enabled: true,
|
||||
alias: 'my-preset',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: true,
|
||||
};
|
||||
|
||||
describe('validateNewPreset', () => {
|
||||
it('accepts a valid preset', () => {
|
||||
const { nextCalled } = runMiddleware(validateNewPreset, { body: validPreset });
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts optional cuesheet options', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, options: { read: 'a', write: 'b' } },
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body.options).toEqual({ read: 'a', write: 'b' });
|
||||
});
|
||||
|
||||
it('rejects a missing required field', () => {
|
||||
const { alias: _alias, ...withoutAlias } = validPreset;
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, { body: withoutAlias });
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects "editor" as a target — URL presets cannot point at the editor view', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, target: OntimeView.Editor },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects an unknown target value', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, target: 'not-a-real-view' },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePresetParam', () => {
|
||||
it('accepts a non-empty alias param', () => {
|
||||
const { nextCalled } = runMiddleware(validatePresetParam, { params: { alias: 'my-preset' } });
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty alias param', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validatePresetParam, { params: { alias: '' } });
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import type { NewPresetInput, PresetAliasParam, UpdatePresetInput } from './urlPresets.validation.js';
|
||||
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -14,80 +15,98 @@ router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
|
||||
res.status(200).send(presets as URLPreset[]);
|
||||
});
|
||||
|
||||
router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const newPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options,
|
||||
};
|
||||
router.post(
|
||||
'/',
|
||||
validateNewPreset,
|
||||
async (
|
||||
req: Request<unknown, URLPreset[] | ErrorResponse, NewPresetInput>,
|
||||
res: Response<URLPreset[] | ErrorResponse>,
|
||||
) => {
|
||||
try {
|
||||
const newPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options,
|
||||
};
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
||||
}
|
||||
|
||||
const newPresets = [...currentPresets, newPreset];
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(201).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const newPresets = [...currentPresets, newPreset];
|
||||
router.put(
|
||||
'/:alias',
|
||||
validateUpdatePreset,
|
||||
async (
|
||||
req: Request<PresetAliasParam, URLPreset[] | ErrorResponse, UpdatePresetInput>,
|
||||
res: Response<URLPreset[] | ErrorResponse>,
|
||||
) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||
if (!existingPreset) {
|
||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||
}
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(201).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
|
||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||
if (!existingPreset) {
|
||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
router.delete(
|
||||
'/:alias',
|
||||
validatePresetParam,
|
||||
async (req: Request<PresetAliasParam>, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:alias', validatePresetParam, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { body, param } from 'express-validator';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { OntimeView, type OntimeViewPresettable } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { validateBody, validateParams } from '../validation-utils/validate.js';
|
||||
|
||||
// URL presets cannot target the editor (see OntimeViewPresettable) — the previous
|
||||
// express-validator check allowed any OntimeView value including 'editor', which URLPreset's
|
||||
// own type never permitted; narrowed here now that the field is properly typed end to end.
|
||||
const presettableViews = Object.values(OntimeView).filter(
|
||||
(view): view is OntimeViewPresettable => view !== OntimeView.Editor,
|
||||
);
|
||||
|
||||
const presetOptionsSchema = z.record(z.string(), z.string()).optional();
|
||||
|
||||
/**
|
||||
* validate array of URL preset objects
|
||||
*/
|
||||
export const validateNewPreset = [
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
body('displayInNav').isBoolean(),
|
||||
|
||||
const newPresetSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
alias: z.string().trim().min(1),
|
||||
target: z.enum(presettableViews),
|
||||
search: z.string().trim(),
|
||||
displayInNav: z.boolean(),
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
options: presetOptionsSchema,
|
||||
});
|
||||
export type NewPresetInput = z.infer<typeof newPresetSchema>;
|
||||
export const validateNewPreset = validateBody(newPresetSchema);
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
const presetAliasParamSchema = z.object({ alias: z.string().trim().min(1) });
|
||||
export type PresetAliasParam = z.infer<typeof presetAliasParamSchema>;
|
||||
export const validatePresetParam = validateParams(presetAliasParamSchema);
|
||||
|
||||
export const validateUpdatePreset = [
|
||||
param('alias').isString().trim().notEmpty(),
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
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(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validatePresetParam = [param('alias').isString().trim().notEmpty(), requestValidationFunction];
|
||||
// update reuses the same body shape as create, plus the alias param check
|
||||
export type UpdatePresetInput = NewPresetInput;
|
||||
export const validateUpdatePreset = [validateParams(presetAliasParamSchema), validateBody(newPresetSchema)];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Exercises a single Express middleware (e.g. validateBody(schema)) against a minimal
|
||||
* fake req/res, without standing up supertest or a running app — matches this codebase's
|
||||
* convention of testing validation logic directly rather than through an HTTP layer.
|
||||
*/
|
||||
export function runMiddleware(
|
||||
middleware: (req: Request, res: Response, next: NextFunction) => void,
|
||||
req: Partial<Request>,
|
||||
) {
|
||||
let statusCode: number | undefined;
|
||||
let payload: unknown;
|
||||
const res = {
|
||||
status(code: number) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(data: unknown) {
|
||||
payload = data;
|
||||
},
|
||||
} as Response;
|
||||
|
||||
let nextCalled = false;
|
||||
middleware(req as Request, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
return { nextCalled, statusCode, payload, req: req as Request };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { z, type ZodType } from 'zod';
|
||||
|
||||
type Target = 'body' | 'params';
|
||||
|
||||
/**
|
||||
* Builds an Express middleware that safe-parses req[target] against `schema`.
|
||||
* - Uses safeParse: no throw/catch on the hot invalid-input path.
|
||||
* - On success, replaces req[target] with the parsed value (defaults filled,
|
||||
* unknown keys stripped, .trim()/.transform() applied) and calls next().
|
||||
* - On failure, responds 422 with { errors: [...] }.
|
||||
*/
|
||||
function validate<T extends ZodType>(target: Target, schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const result = schema.safeParse(req[target]);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
location: target,
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
res.status(422).json({ errors });
|
||||
return;
|
||||
}
|
||||
req[target] = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export const validateBody = <T extends ZodType>(schema: T) => validate('body', schema);
|
||||
export const validateParams = <T extends ZodType>(schema: T) => validate('params', schema);
|
||||
|
||||
/** Direct replacement for the old paramsWithId */
|
||||
export const idParamSchema = z.object({ id: z.string().trim().min(1) });
|
||||
export const validateIdParam = validateParams(idParamSchema);
|
||||
|
||||
/**
|
||||
* Direct replacement for requestValidationFunctionWithFile — unrelated to Zod (it's a
|
||||
* check on multer's req.file, not on body/params shape), kept as its own middleware.
|
||||
*/
|
||||
export function requireUploadedFile(req: Request & { file?: unknown }, res: Response, next: NextFunction) {
|
||||
if (!req.file) {
|
||||
res.status(422).json({ errors: 'File not found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -6,11 +6,13 @@ const editEntryMock = vi.hoisted(() => vi.fn());
|
||||
const groupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const ungroupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const getCurrentRundownMock = vi.hoisted(() => vi.fn());
|
||||
const getProjectCustomFieldsMock = vi.hoisted(() => vi.fn());
|
||||
const createCustomFieldMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getCurrentRundown: getCurrentRundownMock,
|
||||
getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
getProjectCustomFields: getProjectCustomFieldsMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
@@ -20,6 +22,7 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
addEntry: addEntryMock,
|
||||
batchEditEntries: vi.fn(),
|
||||
createCustomField: createCustomFieldMock,
|
||||
deleteEntries: vi.fn(),
|
||||
editEntry: editEntryMock,
|
||||
groupEntries: groupEntriesMock,
|
||||
@@ -27,7 +30,8 @@ vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
ungroupEntries: ungroupEntriesMock,
|
||||
}));
|
||||
|
||||
const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js');
|
||||
const { batchCreateEntriesForMcp, createCustomFieldForMcp, createEntryForMcp, groupEntriesForMcp, ungroupEntryForMcp } =
|
||||
await import('../mcp.service.js');
|
||||
|
||||
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
|
||||
return {
|
||||
@@ -44,6 +48,7 @@ describe('mcp.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCurrentRundownMock.mockReturnValue(makeRundown({}));
|
||||
getProjectCustomFieldsMock.mockReturnValue({});
|
||||
|
||||
let id = 0;
|
||||
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
|
||||
@@ -252,4 +257,42 @@ describe('mcp.service', () => {
|
||||
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
|
||||
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
|
||||
});
|
||||
|
||||
describe('createCustomFieldForMcp', () => {
|
||||
it('creates a field and returns the derived key', async () => {
|
||||
createCustomFieldMock.mockResolvedValue({
|
||||
Camera_Angle: { label: 'Camera Angle', type: 'text', colour: '#3E75E8' },
|
||||
});
|
||||
|
||||
const result = await createCustomFieldForMcp({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
|
||||
|
||||
expect(createCustomFieldMock).toHaveBeenCalledWith({ label: 'Camera Angle', type: 'text', colour: '#3E75E8' });
|
||||
expect(result.key).toBe('Camera_Angle');
|
||||
});
|
||||
|
||||
it('rejects labels the editor UI would not accept', async () => {
|
||||
await expect(createCustomFieldForMcp({ label: 'Camera/GFX', type: 'text', colour: '#000000' })).rejects.toThrow(
|
||||
'Invalid label',
|
||||
);
|
||||
expect(createCustomFieldMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects case-insensitive duplicates and points at the existing key', async () => {
|
||||
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
|
||||
|
||||
await expect(createCustomFieldForMcp({ label: 'camera', type: 'text', colour: '#000000' })).rejects.toThrow(
|
||||
'A custom field with key "Camera" (label "Camera") already exists.',
|
||||
);
|
||||
expect(createCustomFieldMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('suggests the correctly cased key when custom values use the wrong casing', async () => {
|
||||
getProjectCustomFieldsMock.mockReturnValue({ Camera: { label: 'Camera', type: 'text', colour: '' } });
|
||||
|
||||
await expect(createEntryForMcp({ title: 'Talk', custom: { camera: 'CAM 2' } })).rejects.toThrow(
|
||||
'Keys are case-sensitive — did you mean: "camera" → "Camera"?',
|
||||
);
|
||||
expect(addEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
|
||||
editCurrentProjectData: vi.fn(),
|
||||
getProjectData: vi.fn(() => ({ title: 'Test project' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
getRundownMetadata: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
createNewRundown: vi.fn(),
|
||||
deleteRundown: vi.fn(),
|
||||
duplicateExistingRundown: vi.fn(),
|
||||
loadRundown: vi.fn(),
|
||||
renameRundown: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
|
||||
}));
|
||||
|
||||
vi.mock('../../models/dataModel.js', () => ({
|
||||
makeNewProject: vi.fn(() => ({ project: {} })),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/project-service/ProjectService.js', () => ({
|
||||
createProjectWithPatch: vi.fn(),
|
||||
deleteProjectFile: vi.fn(),
|
||||
duplicateProjectFile: vi.fn(),
|
||||
getProjectList: vi.fn(),
|
||||
loadProjectFile: vi.fn(),
|
||||
renameProjectFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/runtimeState.js', () => ({
|
||||
getState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../mcp.service.js', () => ({
|
||||
batchCreateEntriesForMcp: vi.fn(),
|
||||
batchUpdateEntriesForMcp: vi.fn(),
|
||||
createCustomFieldForMcp: vi.fn(),
|
||||
createEntryForMcp: vi.fn(),
|
||||
deleteCustomFieldForMcp: vi.fn(),
|
||||
deleteEntriesForMcp: vi.fn(),
|
||||
findEntry: vi.fn(),
|
||||
getRundownById: vi.fn(() => ({ id: 'r1', order: [], entries: {} })),
|
||||
groupEntriesForMcp: vi.fn(),
|
||||
reorderEntryForMcp: vi.fn(),
|
||||
toRundownList: vi.fn(),
|
||||
ungroupEntryForMcp: vi.fn(),
|
||||
updateCustomFieldForMcp: vi.fn(),
|
||||
updateEntryForMcp: vi.fn(),
|
||||
}));
|
||||
|
||||
const { TOOL_DEFINITIONS, handleToolCall } = await import('../mcp.tools.js');
|
||||
|
||||
describe('MCP tool schema generation', () => {
|
||||
it('generates a well-formed JSON Schema inputSchema for every tool', () => {
|
||||
expect(TOOL_DEFINITIONS.length).toBeGreaterThan(0);
|
||||
for (const tool of TOOL_DEFINITIONS) {
|
||||
expect(tool.inputSchema).toMatchObject({ type: 'object' });
|
||||
expect(typeof tool.inputSchema.properties).toBe('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP tool-call argument validation', () => {
|
||||
it('rejects a required field missing entirely (previously silently miscast)', async () => {
|
||||
const result = await handleToolCall('ontime_create_rundown', {});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a field with the wrong primitive type', async () => {
|
||||
const result = await handleToolCall('ontime_reorder_entry', {
|
||||
entryId: 'a',
|
||||
destinationId: 'b',
|
||||
order: 'sideways', // not one of before/after/insert
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown enum value on a nested field', async () => {
|
||||
const result = await handleToolCall('ontime_create_entry', {
|
||||
type: 'not-a-real-type',
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a minimal valid payload for a tool with no required fields', async () => {
|
||||
const result = await handleToolCall('ontime_get_rundown', {});
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports unknown tool names distinctly from validation failures', async () => {
|
||||
const result = await handleToolCall('ontime_does_not_exist', {});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
|
||||
getProjectData: vi.fn(() => ({ title: 'Test project' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getCurrentRundown: vi.fn(() => ({
|
||||
id: 'rundown-1',
|
||||
title: 'Test',
|
||||
order: [],
|
||||
flatOrder: [],
|
||||
entries: {},
|
||||
revision: 0,
|
||||
})),
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
|
||||
}));
|
||||
|
||||
const { PROMPT_DEFINITIONS, handleGetPrompt } = await import('../mcp.prompts.js');
|
||||
const { RESOURCE_DEFINITIONS, handleReadResource } = await import('../mcp.resources.js');
|
||||
|
||||
describe('MCP resource wiring', () => {
|
||||
it('serves non-empty content for every listed resource', () => {
|
||||
for (const resource of RESOURCE_DEFINITIONS) {
|
||||
const result = handleReadResource(resource.uri);
|
||||
expect(result.contents).toHaveLength(1);
|
||||
expect(result.contents[0].uri).toBe(resource.uri);
|
||||
expect(result.contents[0].mimeType).toBe(resource.mimeType);
|
||||
expect((result.contents[0].text as string).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown resource URIs', () => {
|
||||
expect(() => handleReadResource('ontime://nope')).toThrow('Unknown resource URI: ontime://nope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP prompt wiring', () => {
|
||||
it('resolves every listed prompt', () => {
|
||||
for (const prompt of PROMPT_DEFINITIONS) {
|
||||
const args = Object.fromEntries((prompt.arguments ?? []).map((arg) => [arg.name, 'test value']));
|
||||
const result = handleGetPrompt(prompt.name, args);
|
||||
expect(result.messages.length).toBeGreaterThan(0);
|
||||
const content = result.messages[0].content;
|
||||
expect(content.type).toBe('text');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown prompts', () => {
|
||||
expect(() => handleGetPrompt('nope', {})).toThrow('Unknown prompt: nope');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Agent-facing Ontime guidance documents.
|
||||
*
|
||||
* These markdown resources teach agents rundown craft (ontime://style-guide)
|
||||
* and Ontime's view ecosystem (ontime://views). Guidance should be concise and
|
||||
* clear — verbosity distracts agents and users alike. Keep each document short.
|
||||
*/
|
||||
|
||||
// The palette mirrors the editor colour picker swatches in
|
||||
// apps/client/src/common/components/input/colour-input/SwatchSelect.tsx — keep in sync.
|
||||
export const ONTIME_STYLE_GUIDE_MARKDOWN = `# Ontime rundown style guide
|
||||
|
||||
Conventions for tidy, scannable rundowns. Read before creating or restyling a rundown. Follow the user's own conventions when they have them; otherwise prefer these. When communicating with the user, be concise — short proposals beat long explanations.
|
||||
|
||||
## Structure
|
||||
|
||||
- Group events into blocks matching the show's segments; 10+ ungrouped events is a wall — propose groups.
|
||||
- Set \`targetDuration\` on groups so operators can track time to block completion.
|
||||
- Use \`milestone\` for non-timed markers (doors open, broadcast start), not zero-duration events.
|
||||
- Use \`delay\` only to model schedule drift, never as padding.
|
||||
- Schedule changeovers and buffers as real events (grey; orange when technical), not invisible gaps — 5–10 min between major segments stops one overrun cascading through the day.
|
||||
- Model contingency segments as \`skip: true\` events: visible in the rundown, excluded from playback, one edit to activate.
|
||||
- Before show day, duplicate the rundown for rehearsal so the show copy stays clean.
|
||||
|
||||
## Colours
|
||||
|
||||
Prefer Ontime's default palette — the editor's swatches — so colours look native. Each hue has a dark/light tone pair:
|
||||
|
||||
| Hue | Dark | Light | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| Red | \`#ED3333\` | \`#FF7878\` | attention: curfews, hard deadlines, broadcast-critical |
|
||||
| Orange | \`#FFAB33\` | \`#FFCC78\` | technical changeovers, resets |
|
||||
| Green | \`#339E4E\` | \`#77C785\` | normal show content |
|
||||
| Blue | \`#3E75E8\` | \`#779BE7\` | normal show content |
|
||||
| Violet | \`#8064E1\` | \`#A790F5\` | normal show content |
|
||||
| Grey | \`#9d9d9d\` | \`#ececec\` | breaks, housekeeping |
|
||||
|
||||
- Normal segments use the calm hues (green, blue, violet). Reserve red for what genuinely needs attention and orange for changeovers.
|
||||
- Use tones for hierarchy: dark tone on a group, the light tone of the same hue on its child events.
|
||||
- One convention per project, applied consistently.
|
||||
|
||||
## Cues and titles
|
||||
|
||||
- Cues follow ONE convention per rundown: numeric (\`1, 2, 3\` or \`10, 20, 30\`, leaving room for insertions), semantic (\`Preset\`, \`Key 1\`, \`Lunch\`), or a deliberate mix. Dense technical shows often prefix by department (\`LX1\`, \`SND2\`, \`VID3\`). Match what the rundown already uses; ask if nothing is established.
|
||||
- Cues are automation handles — external systems (Companion, OSC/HTTP) trigger events by cue name. Keep them unique and stable; warn before renaming cues on an existing show.
|
||||
- Title = what happens on stage, under ~50 characters. Department/tech data goes in custom fields; \`note\` is for free-text human context.
|
||||
|
||||
## Custom fields
|
||||
|
||||
- Structured per-department data (camera, graphics, audio, speaker) belongs in custom fields — one per department, coloured to match. This powers the cuesheet and operator views.
|
||||
- Reuse existing fields; never create near-duplicates (\`Cam\`, \`camera\`, \`Cameras\`).
|
||||
- Data repeated in titles or notes ("CAM 2") is a sign it belongs in a custom field.
|
||||
- Write values to be scannable mid-show: "CAM 2 close-up", not sentences.
|
||||
|
||||
## Timing
|
||||
|
||||
- \`linkStart\` events inside a block so schedule edits propagate.
|
||||
- \`flag\` sparingly — it marks critical moments and dilutes with overuse.
|
||||
- Set \`timeWarning\` where a moderator would signal the speaker to wrap (commonly 5 min); \`timeDanger\` for the final push (e.g. 1 min). Scale both down for short items.
|
||||
- Hard out? Backtime: build the closing items from the end with \`timeEnd\` + \`duration\` (Ontime calculates \`timeStart\`), protect their ends with \`timeStrategy: lock-end\`, and use \`countToEnd\` for true curfew items.
|
||||
|
||||
After tidying, read \`ontime://views\` and suggest views that use the new structure.
|
||||
`;
|
||||
|
||||
export const ONTIME_VIEWS_MARKDOWN = `# Ontime views and sharing guide
|
||||
|
||||
Ontime renders the rundown into role-specific views at \`http://<host>:<port>/<view>\`. Recommend the right view when the user mentions a role, device, or sharing need. Prefer sharing live view links over exporting static documents — everyone sees the current version, ending the conflicting-copies problem of circulated spreadsheets. The agent cannot create views or presets — give exact URLs and point to the editor settings.
|
||||
|
||||
| View | Path | For |
|
||||
| --- | --- | --- |
|
||||
| Timer | \`/timer\` | speakers/presenters on stage |
|
||||
| Backstage | \`/backstage\` | crew following schedule + notes |
|
||||
| Operator | \`/op\` | department operators on phone/tablet |
|
||||
| Cuesheet | \`/cuesheet\` | cross-department table editing |
|
||||
| Timeline | \`/timeline\` | producers, whole-show overview |
|
||||
| Countdown | \`/countdown\` | following selected events or a group |
|
||||
| Studio clock | \`/studio\` | on-air studio environments |
|
||||
| Project info | \`/info\` | sharing event logistics widely |
|
||||
|
||||
Most views are configured via URL parameters (editor and cuesheet in-app); the view's cog icon exposes the options and encodes them into the URL.
|
||||
|
||||
## Operator view parameters
|
||||
|
||||
- \`main\` / \`secondary-src\`: the two text lines — \`title\`, \`note\`, or \`custom-<fieldKey>\`.
|
||||
- \`subscribe=<fieldKey>\`: highlight a custom field (repeatable).
|
||||
- \`shouldEdit=true\`: long-press editing of the highlighted field.
|
||||
- \`hidePast=true\`, \`showStart=true\`: trim and annotate the list.
|
||||
|
||||
Example for a camera operator:
|
||||
\`/op?main=title&secondary-src=custom-Camera&subscribe=Camera&hidePast=true\`
|
||||
|
||||
Offer a link like this after creating a department custom field.
|
||||
|
||||
## URL presets
|
||||
|
||||
Presets bundle a configured view under a short alias (e.g. \`/camera-op\`), optionally shown in the nav menu. Managed in **Editor → Settings → URL presets**. When recommending a configured view, spell out the view and query string and suggest saving it as a preset.
|
||||
Docs: https://docs.getontime.no/features/url-presets/
|
||||
|
||||
## Custom views
|
||||
|
||||
For branding or layouts beyond the built-in options: files in Ontime's \`external\` folder are served at \`/external/…\` and can use Ontime's APIs for live data. Recommend for sponsor branding, unusual layouts, or LED wall graphics.
|
||||
Docs: https://docs.getontime.no/features/custom-views/
|
||||
`;
|
||||
@@ -33,6 +33,18 @@ export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tidy_rundown',
|
||||
description:
|
||||
'Audit the rundown for readability (colours, grouping, cue naming, custom field usage) and propose improvements',
|
||||
arguments: [
|
||||
{
|
||||
name: 'focus',
|
||||
description: 'Optional area to focus on, e.g. "colours only" or "grouping"',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'manage_custom_fields',
|
||||
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
|
||||
@@ -105,15 +117,17 @@ 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\`.
|
||||
Structure and colours:
|
||||
- Read the ontime://style-guide resource before deciding on structure or colours.
|
||||
- Propose a group per agenda section (morning block, keynotes, breaks) rather than a flat list — in ontime_batch_create_entries this is just a group entry with \`children\`.
|
||||
- Ask the user what colour convention they use; if they have none, propose one from the default Ontime palette in the style guide: calm hues for normal content, red only for attention items, orange for changeovers, dark tone on a group with the light tone of the same hue on its children. 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\`.
|
||||
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — keys are case-sensitive.
|
||||
- Reuse an existing field when it covers the concept (avoid duplicates such as \`Cam\`, \`camera\`, \`Cameras\`); when the agenda carries structured data with no matching field (speakers, locations), create it with ontime_create_custom_field and use the returned key — no confirmation needed.
|
||||
|
||||
After creating the rundown, read ontime://views and suggest how the team can follow it — e.g. a timer view for speakers, a backstage view for crew, or an operator view subscribed to a department custom field.
|
||||
|
||||
Agenda:
|
||||
${args.agenda}`,
|
||||
@@ -162,6 +176,7 @@ Schedule integrity:
|
||||
- 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
|
||||
- Long segments scheduled back-to-back with no changeover or buffer between them: one overrun will cascade — suggest a buffer event
|
||||
- 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
|
||||
|
||||
@@ -181,6 +196,13 @@ Skipped events:
|
||||
Totals:
|
||||
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
|
||||
|
||||
Readability (report at INFO level; see ontime://style-guide for the conventions):
|
||||
- Colour usage without a consistent convention, or key segments left uncoloured
|
||||
- Long runs of related ungrouped events that would read better as groups
|
||||
- Duplicate cues (cues are automation handles and should be unique) or mixed cue naming conventions
|
||||
- Structured department data in titles/notes that belongs in custom fields
|
||||
- If several readability issues surface, suggest running the tidy_rundown prompt to fix them systematically
|
||||
|
||||
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
|
||||
);
|
||||
}
|
||||
@@ -215,6 +237,34 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'tidy_rundown') {
|
||||
const focus = args.focus ? `\nFocus specifically on: "${args.focus}".` : '';
|
||||
return userPrompt(
|
||||
'Audit and improve rundown readability',
|
||||
`Audit the current Ontime rundown for readability and tidiness, then propose improvements.${focus}
|
||||
|
||||
Steps:
|
||||
1. Read the ontime://style-guide resource — it defines the conventions to audit against, including the standard colour palette.
|
||||
2. 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.
|
||||
3. Call ontime_get_rundown with the chosen \`rundownId\`, and ontime_get_custom_fields for the project field definitions.
|
||||
4. 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.
|
||||
|
||||
Audit for:
|
||||
- Colour consistency: entries coloured outside a discernible convention, missing colours on key segments, red/orange used for normal content (the style guide reserves them for attention items and changeovers), or group/children colours that ignore the dark/light tone pairing. Prefer the default Ontime palette.
|
||||
- Grouping: long runs (roughly 4+) of related consecutive top-level events that would read better as a named group; groups missing \`targetDuration\`.
|
||||
- Buffers: long segments back-to-back with no changeover event between them; propose short buffer events so one overrun cannot cascade.
|
||||
- Cue naming: mixed conventions (numeric vs semantic) without apparent intent, duplicate cues, or gaps that suggest mistakes. Remember cues are automation handles — renaming them can break external triggers, so flag renames explicitly.
|
||||
- Data in the wrong place: structured department data buried in titles or notes (e.g. "CAM 2", "GFX: lower third") that belongs in a custom field; overlong titles (over ~50 chars).
|
||||
- Entry types: zero-duration events that should be milestones; milestones that carry timing expectations and should be events.
|
||||
- Flags: \`flag: true\` on many events dilutes its meaning — critical markers only.
|
||||
|
||||
Then:
|
||||
5. Present the findings as a short, scannable proposal (what changes, on which entries, and why) and WAIT for the user to approve before mutating anything. Be concise — a tight list beats prose.
|
||||
6. Apply approved changes with the cheapest calls: ontime_group_entries to group existing entries, ontime_batch_update_entries when several entries get the same values (e.g. recolouring), ontime_update_entry for per-entry changes, ontime_create_custom_field when moving data into a new field.
|
||||
7. Finish by reading ontime://views and suggesting views that put the improved structure to work — e.g. an operator view subscribed to a department custom field, or a cuesheet for cross-department editing.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'manage_custom_fields') {
|
||||
return userPrompt(
|
||||
'List, create, rename, recolour, or delete custom field definitions',
|
||||
@@ -223,14 +273,14 @@ Efficiency tip: plan moves in the direction of the target position to minimise r
|
||||
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.
|
||||
1. Call ontime_get_custom_fields to see what fields already exist. Reuse an existing field when it covers the concept — avoid duplicates such as "Cam", "camera", and "Cameras".
|
||||
2. Carry out the instruction using the tools below. Creating a field is non-destructive — do it directly. 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.
|
||||
- label: human-readable name (letters, numbers and spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle").
|
||||
- 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.
|
||||
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. If unspecified, pick one from the default Ontime palette matching the department.
|
||||
- Returns: { key, customFields } — use the returned key when setting values on entries.
|
||||
|
||||
Renaming or recolouring a field:
|
||||
@@ -242,7 +292,9 @@ 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.`,
|
||||
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.
|
||||
|
||||
When a field was created for a department (camera, graphics, audio…), read ontime://views and offer the views that surface it: the cuesheet shows the field as a column for cross-department editing, and the operator view can display or highlight it for that department, e.g. /op?main=title&secondary-src=custom-<key>&subscribe=<key>.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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_STYLE_GUIDE_MARKDOWN, ONTIME_VIEWS_MARKDOWN } from './mcp.guides.js';
|
||||
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
|
||||
|
||||
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
@@ -45,6 +46,22 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://style-guide',
|
||||
name: 'ontime-style-guide',
|
||||
title: 'Rundown style guide',
|
||||
description:
|
||||
'Best practices for tidy, easy-to-read rundowns: grouping, colour conventions and the default Ontime palette, cue naming, custom fields vs notes, timing hygiene. Read before creating or restyling a rundown.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://views',
|
||||
name: 'ontime-views',
|
||||
title: 'Views and sharing guide',
|
||||
description:
|
||||
'What each Ontime view (timer, backstage, operator, cuesheet, timeline, countdown, studio clock, project info) is for, key URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://docs',
|
||||
name: 'ontime-docs',
|
||||
@@ -57,6 +74,8 @@ export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
|
||||
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
|
||||
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
|
||||
'ontime://style-guide': { mimeType: 'text/markdown', read: () => ONTIME_STYLE_GUIDE_MARKDOWN },
|
||||
'ontime://views': { mimeType: 'text/markdown', read: () => ONTIME_VIEWS_MARKDOWN },
|
||||
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
|
||||
'ontime://rundown/current': {
|
||||
mimeType: 'application/json',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Agent-facing Ontime MCP documentation.
|
||||
*
|
||||
@@ -9,69 +12,74 @@
|
||||
* 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.
|
||||
// ---- Shared event field schemas ----
|
||||
// Zod shape fragments, spread into z.object({...}) calls in mcp.tools.schema.ts.
|
||||
// Field descriptions carry through into the generated inputSchema (z.toJSONSchema) and
|
||||
// are what the MCP client/LLM actually reads — keep them in sync with reality.
|
||||
|
||||
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:
|
||||
timerType: z
|
||||
.enum(TimerType)
|
||||
.optional()
|
||||
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
|
||||
endAction: z
|
||||
.enum(EndAction)
|
||||
.optional()
|
||||
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
|
||||
linkStart: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"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:
|
||||
),
|
||||
countToEnd: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'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:
|
||||
),
|
||||
timeStrategy: z
|
||||
.enum(TimeStrategy)
|
||||
.optional()
|
||||
.describe(
|
||||
'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;
|
||||
),
|
||||
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
|
||||
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
|
||||
};
|
||||
|
||||
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.',
|
||||
},
|
||||
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
|
||||
title: z.string().optional().describe('Event title shown in the rundown and views'),
|
||||
note: z.string().optional().describe('Free-text note for production notes or references'),
|
||||
colour: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
|
||||
),
|
||||
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
|
||||
flag: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
|
||||
custom: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
|
||||
),
|
||||
...EVENT_TIMER_FIELDS,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const RUNDOWN_TARGET_FIELD = {
|
||||
rundownId: {
|
||||
type: 'string',
|
||||
description:
|
||||
rundownId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'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
|
||||
@@ -180,12 +188,11 @@ 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 }\`.
|
||||
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). 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\`.
|
||||
Keys are case-sensitive. When setting \`custom\` values, reuse an existing field when it covers the concept; otherwise create the missing field — creation is non-destructive and needs no confirmation. Rename and delete are the destructive operations: confirm those with the user.
|
||||
|
||||
## Targeting rundowns
|
||||
Entry read/write tools accept an optional \`rundownId\`.
|
||||
@@ -199,6 +206,8 @@ When playback is not \`stop\`, mutations to the loaded rundown affect the live r
|
||||
|
||||
## Available resources
|
||||
- \`ontime://schema\` — this document
|
||||
- \`ontime://style-guide\` — rundown design best practices: grouping, colour palette, cue naming, custom field usage. Read before creating or restyling a rundown.
|
||||
- \`ontime://views\` — Ontime's role-specific views, URL parameters, URL presets, and custom views. Read when recommending how a team member should follow or edit the show.
|
||||
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
|
||||
- \`ontime://rundowns\` — all rundowns in the project (JSON)
|
||||
- \`ontime://project/info\` — project metadata (JSON)
|
||||
@@ -234,6 +243,16 @@ Main site: https://docs.getontime.no
|
||||
- OSC Integration: https://docs.getontime.no/api/osc/
|
||||
- Make custom views: https://docs.getontime.no/features/custom-views/
|
||||
|
||||
## Views
|
||||
- Configuring views: https://docs.getontime.no/quick-tips/configuring-views/
|
||||
- Operator view: https://docs.getontime.no/interface/production/operator/
|
||||
- Cuesheet: https://docs.getontime.no/interface/production/cuesheet/
|
||||
- Render views in third-party apps: https://docs.getontime.no/quick-tips/render-elsewhere/
|
||||
|
||||
## Quick tips
|
||||
- Managing delays: https://docs.getontime.no/quick-tips/managing-delays/
|
||||
- Smart time entry: https://docs.getontime.no/quick-tips/smart-time-entry/
|
||||
|
||||
## Helpful references
|
||||
- Ontime Cloud: https://docs.getontime.no/ontime-cloud/
|
||||
- Connect to companion module: https://docs.getontime.no/additional-notes/companion-module/
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import {
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
InsertOptions,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
@@ -28,40 +25,25 @@ import {
|
||||
} 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 };
|
||||
// *Args types are now derived from the Zod schemas in mcp.tools.schema.ts — that file is
|
||||
// the single source of truth for MCP tool input shape, validation, and typing.
|
||||
import type {
|
||||
BatchCreateEntriesArgs,
|
||||
BatchCreateEntryArgs,
|
||||
BatchUpdateEntriesArgs,
|
||||
CreateCustomFieldArgs,
|
||||
CreateEntryArgs,
|
||||
DeleteCustomFieldArgs,
|
||||
DeleteEntriesArgs,
|
||||
EntryFieldArgs,
|
||||
GetEntryArgs,
|
||||
GroupEntriesArgs,
|
||||
ReorderEntryArgs,
|
||||
TargetRundownArgs,
|
||||
UngroupEntryArgs,
|
||||
UpdateCustomFieldArgs,
|
||||
UpdateEntryArgs,
|
||||
} from './mcp.tools.schema.js';
|
||||
|
||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||
return args.rundownId ?? getCurrentRundownId();
|
||||
@@ -77,7 +59,7 @@ export function getRundownById(rundownId?: string): Readonly<Rundown> {
|
||||
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
||||
}
|
||||
|
||||
export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined {
|
||||
export function findEntry(args: GetEntryArgs): OntimeEntry | undefined {
|
||||
const rundown = getRundownById(args.rundownId);
|
||||
if (args.id) {
|
||||
return rundown.entries[args.id];
|
||||
@@ -110,11 +92,21 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
||||
const available = Object.keys(customFields);
|
||||
const hint =
|
||||
available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
|
||||
|
||||
// keys are case-sensitive; a near-miss on casing is the most common mistake
|
||||
const suggestions = [...unknownKeys]
|
||||
.map((key) => {
|
||||
const match = available.find((existing) => existing.toLowerCase() === key.toLowerCase());
|
||||
return match ? `"${key}" → "${match}"` : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const caseHint =
|
||||
suggestions.length > 0 ? ` Keys are case-sensitive — did you mean: ${suggestions.join(', ')}?` : '';
|
||||
|
||||
throw new Error(
|
||||
`Unknown custom field key(s): ${missing}. ${hint} ` +
|
||||
`Unknown custom field key(s): ${missing}. ${hint}${caseHint} ` +
|
||||
`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.`,
|
||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,15 +175,13 @@ export async function updateEntryForMcp(args: UpdateEntryArgs) {
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) {
|
||||
export async function deleteEntriesForMcp(args: DeleteEntriesArgs) {
|
||||
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' },
|
||||
) {
|
||||
export async function reorderEntryForMcp(args: ReorderEntryArgs) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
||||
return { target: getTargetMeta(rundownId), order: rundown.order };
|
||||
@@ -248,9 +238,7 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function batchCreateEntriesForMcp(
|
||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
||||
) {
|
||||
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
||||
const { entries = [], after } = args;
|
||||
validateBatchCreateEntries(entries);
|
||||
const allEntries = flattenBatchCreateEntries(entries);
|
||||
@@ -325,26 +313,41 @@ async function createBatchEntry(
|
||||
return { entry, created };
|
||||
}
|
||||
|
||||
export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) {
|
||||
export async function batchUpdateEntriesForMcp(args: BatchUpdateEntriesArgs) {
|
||||
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;
|
||||
export async function createCustomFieldForMcp(args: CreateCustomFieldArgs) {
|
||||
const label = args.label?.trim();
|
||||
// same constraint the HTTP route enforces in customFields.validation.ts
|
||||
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
|
||||
throw new Error('Invalid label: use letters, numbers and spaces only, e.g. "Camera Angle".');
|
||||
}
|
||||
|
||||
const key = customFieldLabelToKey(label);
|
||||
const existingFields = getProjectCustomFields();
|
||||
const clash = Object.keys(existingFields).find((existing) => existing.toLowerCase() === key.toLowerCase());
|
||||
if (clash) {
|
||||
throw new Error(
|
||||
`A custom field with key "${clash}" (label "${existingFields[clash].label}") already exists. ` +
|
||||
`Reuse it in entry.custom["${clash}"] instead of creating a duplicate, or pick a clearly different label.`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await createCustomField({ label, type: args.type, colour: args.colour });
|
||||
return { key, customFields: updated };
|
||||
}
|
||||
|
||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
||||
export async function updateCustomFieldForMcp(args: UpdateCustomFieldArgs) {
|
||||
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 }) {
|
||||
export async function deleteCustomFieldForMcp(args: DeleteCustomFieldArgs) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
||||
return { customFields: updated };
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Zod schemas for MCP tool inputs.
|
||||
*
|
||||
* Each schema is the single source of truth for three things:
|
||||
* - the generated `inputSchema` served to MCP clients (via z.toJSONSchema in mcp.tools.ts)
|
||||
* - runtime validation of incoming tool-call arguments (via .safeParse in mcp.tools.ts)
|
||||
* - the TypeScript types used by mcp.service.ts's business logic
|
||||
*
|
||||
* Field shapes intentionally mirror the hand-written JSON Schema this file replaces, not
|
||||
* the full canonical domain types in ontime-types — e.g. `colour` stays a plain string,
|
||||
* matching what was (not) validated before this migration.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
||||
|
||||
// ---- Shared fragments ----
|
||||
|
||||
const entryTimingFields = {
|
||||
timeStart: z.number().optional().describe('Start time in ms from midnight'),
|
||||
timeEnd: z.number().optional().describe('End time in ms from midnight'),
|
||||
duration: z.number().optional().describe('Duration in ms'),
|
||||
targetDuration: z.number().optional().describe('Groups only: planned length of the group in ms'),
|
||||
};
|
||||
|
||||
// All writable fields of any entry type, flattened — reused as the base for create/update/
|
||||
// batch schemas via .extend(). Matches the previous EntryFieldArgs shape.
|
||||
const entryFieldsSchema = z.object({ ...entryTimingFields, ...EVENT_WRITABLE_FIELDS });
|
||||
export type EntryFieldArgs = z.infer<typeof entryFieldsSchema>;
|
||||
|
||||
const groupWritableFields = {
|
||||
title: z.string().optional().describe('Group title shown in the rundown and views'),
|
||||
note: z.string().optional().describe('Free-text group note for production notes or references'),
|
||||
colour: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide'),
|
||||
custom: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe('Custom field values keyed by existing project field key'),
|
||||
targetDuration: z.number().optional().describe('Planned length of the group in ms'),
|
||||
};
|
||||
|
||||
// ---- Rundown read ----
|
||||
|
||||
export const getRundownSchema = z.object({ ...RUNDOWN_TARGET_FIELD });
|
||||
export const getRundownMetadataSchema = z.object({});
|
||||
export const getEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().optional().describe('Entry ID (from rundown.entries key or entry.id)'),
|
||||
cue: z.string().optional().describe('Human-facing cue label'),
|
||||
});
|
||||
|
||||
// ---- Rundown mutations ----
|
||||
|
||||
export const createEntrySchema = entryFieldsSchema.extend({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
type: z
|
||||
.enum(['event', 'delay', 'milestone', 'group'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries',
|
||||
),
|
||||
// Overrides entryFieldsSchema's generic timing descriptions with create-specific guidance.
|
||||
timeStart: z.number().optional().describe('Event start time in ms from midnight (e.g. 09:00 = 32400000)'),
|
||||
timeEnd: z.number().optional().describe('Event end time in ms from midnight'),
|
||||
duration: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)'),
|
||||
after: z.string().optional().describe('Insert after this entry ID'),
|
||||
before: z.string().optional().describe('Insert before this entry ID'),
|
||||
});
|
||||
|
||||
export const updateEntrySchema = entryFieldsSchema.extend({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().describe('ID of the entry to update'),
|
||||
});
|
||||
|
||||
export const deleteEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Array of entry IDs to delete'),
|
||||
});
|
||||
|
||||
export const reorderEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
entryId: z.string().describe('ID of the entry to move'),
|
||||
destinationId: z.string().describe('ID of the target entry (sibling or parent group)'),
|
||||
order: z.enum(['before', 'after', 'insert']).describe('before/after: place as sibling; insert: place inside a group'),
|
||||
});
|
||||
|
||||
export const groupEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Existing top-level entry IDs to group'),
|
||||
...groupWritableFields,
|
||||
});
|
||||
|
||||
export const ungroupEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().describe('Group entry ID to dissolve'),
|
||||
});
|
||||
|
||||
// Recursive: a group entry in a batch may include `children` of the same shape. `type`/
|
||||
// `children` are declared outside entryFieldsSchema so the lazy() wrapper can reference
|
||||
// the schema being defined.
|
||||
export interface BatchCreateEntryArgs extends EntryFieldArgs {
|
||||
type?: 'event' | 'delay' | 'milestone' | 'group';
|
||||
children?: BatchCreateEntryArgs[];
|
||||
}
|
||||
|
||||
export const batchCreateEntrySchema: z.ZodType<BatchCreateEntryArgs> = z.lazy(() =>
|
||||
entryFieldsSchema.extend({
|
||||
type: z.enum(['event', 'delay', 'milestone', 'group']).optional().describe('Entry type, defaults to event'),
|
||||
children: z
|
||||
.array(batchCreateEntrySchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const batchCreateEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: z.string().optional().describe('Insert the first entry after this entry ID'),
|
||||
entries: z.array(batchCreateEntrySchema).describe('Array of entries to create, in desired order'),
|
||||
});
|
||||
|
||||
export const batchUpdateEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Array of entry IDs to update'),
|
||||
data: entryFieldsSchema.describe('Partial entry fields to apply to every ID'),
|
||||
});
|
||||
|
||||
// ---- Rundown management ----
|
||||
|
||||
export const listRundownsSchema = z.object({});
|
||||
export const createRundownSchema = z.object({ title: z.string().describe('Title for the new rundown') });
|
||||
export const loadRundownSchema = z.object({ id: z.string().describe('Rundown ID to load') });
|
||||
export const renameRundownSchema = z.object({
|
||||
id: z.string().describe('Rundown ID to rename'),
|
||||
title: z.string().describe('New title'),
|
||||
});
|
||||
export const deleteRundownSchema = z.object({ id: z.string().describe('Rundown ID to delete') });
|
||||
export const duplicateRundownSchema = z.object({ id: z.string().describe('Rundown ID to duplicate') });
|
||||
|
||||
// ---- Timer & project ----
|
||||
|
||||
export const getTimerStateSchema = z.object({});
|
||||
export const getProjectInfoSchema = z.object({});
|
||||
export const updateProjectInfoSchema = z.object({
|
||||
title: z.string().optional().describe('Project title'),
|
||||
description: z.string().optional().describe('Project description'),
|
||||
url: z.string().optional().describe('URL shown on viewer pages'),
|
||||
info: z.string().optional().describe('Info text shown on viewer pages'),
|
||||
});
|
||||
export const getCustomFieldsSchema = z.object({});
|
||||
export const createCustomFieldSchema = z.object({
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
|
||||
),
|
||||
type: z
|
||||
.enum(['text', 'image'])
|
||||
.describe(
|
||||
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
|
||||
),
|
||||
colour: z
|
||||
.string()
|
||||
.describe(
|
||||
'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).',
|
||||
),
|
||||
});
|
||||
export const updateCustomFieldSchema = z.object({
|
||||
key: z.string().describe('Current field key (from ontime_get_custom_fields)'),
|
||||
label: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New human-readable label (optional). Changes the derived key and cascades to all entries.'),
|
||||
colour: z.string().optional().describe('New hex colour (#RRGGBB) (optional)'),
|
||||
});
|
||||
export const deleteCustomFieldSchema = z.object({
|
||||
key: z.string().describe('Field key to delete (from ontime_get_custom_fields)'),
|
||||
});
|
||||
|
||||
// ---- Project file management ----
|
||||
|
||||
export const listProjectsSchema = z.object({});
|
||||
export const loadProjectSchema = z.object({
|
||||
filename: z.string().describe('Project filename, e.g. "my-show.json"'),
|
||||
});
|
||||
export const createProjectSchema = z.object({
|
||||
filename: z.string().describe('Filename without extension, e.g. "my-show"'),
|
||||
title: z.string().optional().describe('Optional project title'),
|
||||
description: z.string().optional().describe('Optional project description'),
|
||||
});
|
||||
export const renameProjectSchema = z.object({
|
||||
filename: z.string().describe('Current filename (with .json extension)'),
|
||||
newFilename: z.string().describe('New filename (with .json extension)'),
|
||||
});
|
||||
export const duplicateProjectSchema = z.object({
|
||||
filename: z.string().describe('Source filename to copy (with .json extension)'),
|
||||
newFilename: z.string().describe('Filename of the new copy (with .json extension)'),
|
||||
});
|
||||
export const deleteProjectSchema = z.object({
|
||||
filename: z.string().describe('Project filename to delete (with .json extension)'),
|
||||
});
|
||||
|
||||
// ---- Inferred types consumed by mcp.service.ts (replaces its hand-written *Args types) ----
|
||||
|
||||
export type TargetRundownArgs = z.infer<typeof getRundownSchema>;
|
||||
export type GetEntryArgs = z.infer<typeof getEntrySchema>;
|
||||
export type CreateEntryArgs = z.infer<typeof createEntrySchema>;
|
||||
export type UpdateEntryArgs = z.infer<typeof updateEntrySchema>;
|
||||
export type DeleteEntriesArgs = z.infer<typeof deleteEntriesSchema>;
|
||||
export type ReorderEntryArgs = z.infer<typeof reorderEntrySchema>;
|
||||
export type GroupEntriesArgs = z.infer<typeof groupEntriesSchema>;
|
||||
export type UngroupEntryArgs = z.infer<typeof ungroupEntrySchema>;
|
||||
export type BatchCreateEntriesArgs = z.infer<typeof batchCreateEntriesSchema>;
|
||||
export type BatchUpdateEntriesArgs = z.infer<typeof batchUpdateEntriesSchema>;
|
||||
export type ProjectInfoArgs = z.infer<typeof updateProjectInfoSchema>;
|
||||
export type CreateCustomFieldArgs = z.infer<typeof createCustomFieldSchema>;
|
||||
export type UpdateCustomFieldArgs = z.infer<typeof updateCustomFieldSchema>;
|
||||
export type DeleteCustomFieldArgs = z.infer<typeof deleteCustomFieldSchema>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { EntryId, ProjectData } from 'ontime-types';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
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,
|
||||
@@ -37,14 +37,19 @@ import {
|
||||
ungroupEntryForMcp,
|
||||
updateCustomFieldForMcp,
|
||||
updateEntryForMcp,
|
||||
type BatchCreateEntryArgs,
|
||||
type CreateEntryArgs,
|
||||
type EntryFieldArgs,
|
||||
type GroupEntriesArgs,
|
||||
type TargetRundownArgs,
|
||||
type UngroupEntryArgs,
|
||||
type UpdateEntryArgs,
|
||||
} from './mcp.service.js';
|
||||
import * as schemas from './mcp.tools.schema.js';
|
||||
|
||||
/**
|
||||
* Parses tool-call arguments against `schema`, throwing on failure. handleToolCall (below)
|
||||
* already wraps every handler in try/catch and formats thrown errors into a CallToolResult,
|
||||
* so this reuses that existing error path rather than inventing a second one — unlike the
|
||||
* REST validation layer (apps/server/src/api-data/validation-utils/validate.ts), tool calls
|
||||
* are not a hot request path, so .parse()'s throw-based control flow costs nothing here.
|
||||
*/
|
||||
function parseArgs<T extends z.ZodType>(schema: T, args: Record<string, unknown>): z.infer<T> {
|
||||
return schema.parse(args);
|
||||
}
|
||||
|
||||
// Graceful truncation to keep tool responses within typical MCP context windows
|
||||
const CHARACTER_LIMIT = 25_000;
|
||||
@@ -68,28 +73,21 @@ export const TOOL_DEFINITIONS = [
|
||||
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 } },
|
||||
inputSchema: z.toJSONSchema(schemas.getRundownSchema),
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getRundownMetadataSchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.getEntrySchema),
|
||||
annotations: READ,
|
||||
},
|
||||
// --- Rundown mutations ---
|
||||
@@ -97,182 +95,56 @@ export const TOOL_DEFINITIONS = [
|
||||
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,
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createEntrySchema),
|
||||
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,
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateEntrySchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteEntriesSchema),
|
||||
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',
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.reorderEntrySchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.groupEntriesSchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.ungroupEntrySchema),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.batchCreateEntriesSchema),
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.batchUpdateEntriesSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Rundown management ---
|
||||
@@ -280,62 +152,39 @@ export const TOOL_DEFINITIONS = [
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.listRundownsSchema),
|
||||
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' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createRundownSchema),
|
||||
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' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.loadRundownSchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.renameRundownSchema),
|
||||
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' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteRundownSchema),
|
||||
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' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.duplicateRundownSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
// --- Timer & project ---
|
||||
@@ -343,170 +192,93 @@ export const TOOL_DEFINITIONS = [
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getTimerStateSchema),
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getProjectInfoSchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateProjectInfoSchema),
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getCustomFieldsSchema),
|
||||
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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'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"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.',
|
||||
inputSchema: z.toJSONSchema(schemas.createCustomFieldSchema),
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateCustomFieldSchema),
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteCustomFieldSchema),
|
||||
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: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.listProjectsSchema),
|
||||
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"' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.loadProjectSchema),
|
||||
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' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createProjectSchema),
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.renameProjectSchema),
|
||||
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)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.duplicateProjectSchema),
|
||||
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)' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteProjectSchema),
|
||||
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);
|
||||
@@ -523,7 +295,7 @@ export const err = (e: unknown): CallToolResult => ({
|
||||
// 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 targetArgs = parseArgs(schemas.getRundownSchema, args);
|
||||
const rundown = getRundownById(targetArgs.rundownId);
|
||||
const data = { order: rundown.order, entries: rundown.entries };
|
||||
const serialised = text(data);
|
||||
@@ -541,7 +313,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
ontime_get_rundown_metadata: async () => ok(getRundownMetadata()),
|
||||
|
||||
ontime_get_entry: async (args) => {
|
||||
const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string };
|
||||
const entryArgs = parseArgs(schemas.getEntrySchema, args);
|
||||
const entry = findEntry(entryArgs);
|
||||
if (entry) return ok(entry);
|
||||
if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`);
|
||||
@@ -550,71 +322,61 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
},
|
||||
|
||||
ontime_create_entry: async (args) => {
|
||||
return ok(await createEntryForMcp(args as CreateEntryArgs));
|
||||
return ok(await createEntryForMcp(parseArgs(schemas.createEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_update_entry: async (args) => {
|
||||
return ok(await updateEntryForMcp(args as UpdateEntryArgs));
|
||||
return ok(await updateEntryForMcp(parseArgs(schemas.updateEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_delete_entries: async (args) => {
|
||||
return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] }));
|
||||
return ok(await deleteEntriesForMcp(parseArgs(schemas.deleteEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_reorder_entry: async (args) => {
|
||||
return ok(
|
||||
await reorderEntryForMcp(
|
||||
args as TargetRundownArgs & {
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
},
|
||||
),
|
||||
);
|
||||
return ok(await reorderEntryForMcp(parseArgs(schemas.reorderEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_group_entries: async (args) => {
|
||||
return ok(await groupEntriesForMcp(args as GroupEntriesArgs));
|
||||
return ok(await groupEntriesForMcp(parseArgs(schemas.groupEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_ungroup_entry: async (args) => {
|
||||
return ok(await ungroupEntryForMcp(args as UngroupEntryArgs));
|
||||
return ok(await ungroupEntryForMcp(parseArgs(schemas.ungroupEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_batch_create_entries: async (args) => {
|
||||
return ok(
|
||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
||||
);
|
||||
return ok(await batchCreateEntriesForMcp(parseArgs(schemas.batchCreateEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_batch_update_entries: async (args) => {
|
||||
return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }));
|
||||
return ok(await batchUpdateEntriesForMcp(parseArgs(schemas.batchUpdateEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())),
|
||||
|
||||
ontime_create_rundown: async (args) => {
|
||||
const { title } = args as { title: string };
|
||||
const { title } = parseArgs(schemas.createRundownSchema, args);
|
||||
return ok(toRundownList(await createNewRundown(title)));
|
||||
},
|
||||
|
||||
ontime_load_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.loadRundownSchema, args);
|
||||
return ok(toRundownList(await loadRundown(id)));
|
||||
},
|
||||
|
||||
ontime_rename_rundown: async (args) => {
|
||||
const { id, title } = args as { id: string; title: string };
|
||||
const { id, title } = parseArgs(schemas.renameRundownSchema, args);
|
||||
return ok(toRundownList(await renameRundown(id, title)));
|
||||
},
|
||||
|
||||
ontime_delete_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.deleteRundownSchema, args);
|
||||
return ok(toRundownList(await deleteRundown(id)));
|
||||
},
|
||||
|
||||
ontime_duplicate_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.duplicateRundownSchema, args);
|
||||
return ok(toRundownList(await duplicateExistingRundown(id)));
|
||||
},
|
||||
|
||||
@@ -626,61 +388,53 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
ontime_get_project_info: async () => ok(getProjectData()),
|
||||
|
||||
ontime_update_project_info: async (args) => {
|
||||
const updated = await editCurrentProjectData(args as ProjectInfoArgs);
|
||||
const updated = await editCurrentProjectData(parseArgs(schemas.updateProjectInfoSchema, args));
|
||||
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 }));
|
||||
return ok(await createCustomFieldForMcp(parseArgs(schemas.createCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_update_custom_field: async (args) => {
|
||||
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
|
||||
return ok(await updateCustomFieldForMcp(parseArgs(schemas.updateCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_delete_custom_field: async (args) => {
|
||||
return ok(await deleteCustomFieldForMcp(args as { key: string }));
|
||||
return ok(await deleteCustomFieldForMcp(parseArgs(schemas.deleteCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_list_projects: async () => ok(await getProjectList()),
|
||||
|
||||
ontime_load_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
const { filename } = parseArgs(schemas.loadProjectSchema, args);
|
||||
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 { filename, title = '', description = '' } = parseArgs(schemas.createProjectSchema, args);
|
||||
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 };
|
||||
const { filename, newFilename } = parseArgs(schemas.renameProjectSchema, args);
|
||||
await renameProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_duplicate_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
const { filename, newFilename } = parseArgs(schemas.duplicateProjectSchema, args);
|
||||
await duplicateProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_delete_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
const { filename } = parseArgs(schemas.deleteProjectSchema, args);
|
||||
await deleteProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RuntimeState } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
@@ -15,6 +16,56 @@ import {
|
||||
|
||||
const asTimeOfDay = (value: number): RuntimeState['clock'] => value as RuntimeState['clock'];
|
||||
|
||||
describe('getElapsed()', () => {
|
||||
it('returns active elapsed time from startedAt without add-time adjustments', () => {
|
||||
const state = {
|
||||
clock: 5 * MILLIS_PER_MINUTE,
|
||||
timer: {
|
||||
addedTime: -10 * MILLIS_PER_MINUTE,
|
||||
current: 15 * MILLIS_PER_MINUTE,
|
||||
duration: 20 * MILLIS_PER_MINUTE,
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('subtracts accumulated pause time', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
pausedDuration: 5 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('uses the current pause start while paused', () => {
|
||||
const state = {
|
||||
clock: 10 * MILLIS_PER_MINUTE,
|
||||
timer: {
|
||||
startedAt: 2 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 7 * MILLIS_PER_MINUTE,
|
||||
pausedDuration: 1 * MILLIS_PER_MINUTE,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
expect(getElapsed(state)).toBe(4 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpectedFinish()', () => {
|
||||
it('is null if we havent started', () => {
|
||||
const state = {
|
||||
|
||||
@@ -47,6 +47,23 @@ describe('isRestorePoint()', () => {
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts optional paused duration', () => {
|
||||
const restorePoint: RestorePoint = {
|
||||
playback: Playback.Roll,
|
||||
selectedEventId: '123',
|
||||
startedAt: 1,
|
||||
addedTime: 2,
|
||||
pausedAt: null,
|
||||
pausedDuration: 3000,
|
||||
firstStart: 1,
|
||||
startEpoch: asInstant(1),
|
||||
currentDay: 0,
|
||||
};
|
||||
|
||||
expect(isRestorePoint(restorePoint)).toBe(true);
|
||||
expect(isRestorePoint({ ...restorePoint, pausedDuration: '3000' })).toBe(false);
|
||||
});
|
||||
|
||||
describe('rejects a badly formatted file', () => {
|
||||
it('with invalid playback value', () => {
|
||||
const restorePoint = {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function isRestorePoint(restorePoint: unknown): restorePoint is RestorePo
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('pausedDuration' in restorePoint && !is.number(restorePoint.pausedDuration)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is.number(restorePoint.firstStart) && restorePoint.firstStart !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RestorePoint = {
|
||||
startedAt: MaybeNumber;
|
||||
addedTime: number;
|
||||
pausedAt: MaybeNumber;
|
||||
pausedDuration?: number;
|
||||
firstStart: MaybeNumber;
|
||||
startEpoch: Maybe<Instant>;
|
||||
currentDay: MaybeNumber;
|
||||
|
||||
@@ -760,6 +760,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
startedAt: state.timer.startedAt,
|
||||
addedTime: state.timer.addedTime,
|
||||
pausedAt: state._timer.pausedAt,
|
||||
pausedDuration: state._timer.pausedDuration,
|
||||
firstStart: state.rundown.actualStart,
|
||||
startEpoch: state._startEpoch,
|
||||
currentDay: state.rundown.currentDay,
|
||||
|
||||
@@ -33,7 +33,8 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
|
||||
* Checks whether we should update the timer values
|
||||
* - `current` and `secondaryTimer` trigger on seconds roll over
|
||||
* - the rest trigger on any change
|
||||
* - `elapsed` and `expectedFinish` is not checked
|
||||
* - `elapsed` changes alongside `current`
|
||||
* - `expectedFinish` is not checked
|
||||
*/
|
||||
export function getShouldTimerUpdate(previousValue: TimerState | undefined, currentValue: TimerState): boolean {
|
||||
if (previousValue === undefined) return true;
|
||||
@@ -48,7 +49,7 @@ export function getShouldTimerUpdate(previousValue: TimerState | undefined, curr
|
||||
previousValue.phase !== currentValue.phase ||
|
||||
previousValue.playback !== currentValue.playback ||
|
||||
previousValue.startedAt !== currentValue.startedAt
|
||||
// elapsed - this would be the direct invert of current value so no need to check
|
||||
// elapsed - this is derived from current / duration / addedTime, so the fields above cover it
|
||||
// expectedFinish - this will be moved out by the current value going into over time, no need to check
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,33 @@ export function getCurrent(state: RuntimeState): number {
|
||||
return startedAt + duration + addedTime - clock - correctDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates active time elapsed since the timer started.
|
||||
*/
|
||||
export function getElapsed(state: RuntimeState): MaybeNumber {
|
||||
const { clock } = state;
|
||||
const { startedAt } = state.timer;
|
||||
const { pausedAt, pausedDuration } = state._timer;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const referenceClock = pausedAt ?? clock;
|
||||
const elapsedSinceStart = getTimeSinceStart(referenceClock, startedAt);
|
||||
const activeElapsed = elapsedSinceStart - pausedDuration;
|
||||
|
||||
return Math.max(0, activeElapsed);
|
||||
}
|
||||
|
||||
function getTimeSinceStart(clock: TimeOfDay, startedAt: number): number {
|
||||
if (clock < startedAt) {
|
||||
return clock + dayInMs - startedAt;
|
||||
}
|
||||
|
||||
return clock - startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we have skipped out of the event
|
||||
* @param {RuntimeState} state runtime state
|
||||
|
||||
@@ -40,6 +40,7 @@ const baseState: RuntimeState = {
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
secondaryTarget: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
|
||||
@@ -61,6 +61,13 @@ vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
return {
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
getAutomation: vi.fn().mockReturnValue({
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 0,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -168,6 +175,79 @@ describe('mutation on runtimeState', () => {
|
||||
});
|
||||
expect(newState.rundown.actualStart).toBeNull();
|
||||
});
|
||||
|
||||
test('elapsed remains active time when adding and removing time', async () => {
|
||||
clearState();
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-add-time',
|
||||
timeStart: 0,
|
||||
timeEnd: 10 * MILLIS_PER_MINUTE,
|
||||
duration: 10 * MILLIS_PER_MINUTE,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
vi.setSystemTime('jan 1 00:00');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
vi.setSystemTime('jan 1 00:02');
|
||||
update();
|
||||
|
||||
addTime(5 * MILLIS_PER_MINUTE);
|
||||
let state = getState();
|
||||
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||
expect(state.timer.duration! - state.timer.current!).toBe(-3 * MILLIS_PER_MINUTE);
|
||||
|
||||
addTime(-8 * MILLIS_PER_MINUTE);
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||
expect(state.timer.duration! - state.timer.current!).toBe(5 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
test('elapsed excludes time spent paused after resume', async () => {
|
||||
clearState();
|
||||
const event = {
|
||||
...mockEvent,
|
||||
id: 'elapsed-pause',
|
||||
timeStart: 0,
|
||||
timeEnd: 10 * MILLIS_PER_MINUTE,
|
||||
duration: 10 * MILLIS_PER_MINUTE,
|
||||
};
|
||||
const mockRundown = makeRundown({
|
||||
entries: { [event.id]: event },
|
||||
order: [event.id],
|
||||
});
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
vi.setSystemTime('jan 1 00:00');
|
||||
load(event, rundown, metadata);
|
||||
start();
|
||||
|
||||
vi.setSystemTime('jan 1 00:02');
|
||||
update();
|
||||
pause();
|
||||
|
||||
vi.setSystemTime('jan 1 00:07');
|
||||
start();
|
||||
let state = getState();
|
||||
expect(state.timer.elapsed).toBe(2 * MILLIS_PER_MINUTE);
|
||||
|
||||
vi.setSystemTime('jan 1 00:08');
|
||||
update();
|
||||
state = getState();
|
||||
expect(state.timer.elapsed).toBe(3 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
test('runtime offset', async () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
|
||||
import {
|
||||
findDayOffset,
|
||||
getCurrent,
|
||||
getElapsed,
|
||||
getExpectedFinish,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
@@ -62,6 +63,7 @@ export type RuntimeState = {
|
||||
_timer: {
|
||||
forceFinish: Maybe<TimeOfDay>; // whether we should declare an event as finished, will contain the finish time
|
||||
pausedAt: Maybe<TimeOfDay>;
|
||||
pausedDuration: number;
|
||||
secondaryTarget: Maybe<TimeOfDay>;
|
||||
hasFinished: boolean;
|
||||
};
|
||||
@@ -87,6 +89,7 @@ const runtimeState: RuntimeState = {
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
pausedAt: null,
|
||||
pausedDuration: 0,
|
||||
secondaryTarget: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
@@ -138,6 +141,7 @@ export function clearEventData() {
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
runtimeState._timer.forceFinish = null;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.pausedDuration = 0;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
@@ -170,6 +174,7 @@ export function clearState() {
|
||||
// when clearing, we maintain the total delay from the rundown
|
||||
runtimeState._timer.forceFinish = null;
|
||||
runtimeState._timer.pausedAt = null;
|
||||
runtimeState._timer.pausedDuration = 0;
|
||||
runtimeState._timer.secondaryTarget = null;
|
||||
runtimeState._timer.hasFinished = false;
|
||||
|
||||
@@ -426,6 +431,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
if (state._timer.pausedAt) {
|
||||
const timeToAdd = state.clock - state._timer.pausedAt;
|
||||
state.timer.addedTime += timeToAdd;
|
||||
state._timer.pausedDuration += timeToAdd;
|
||||
state._timer.pausedAt = null;
|
||||
}
|
||||
|
||||
@@ -435,7 +441,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
|
||||
state.timer.playback = Playback.Play;
|
||||
state.timer.expectedFinish = getExpectedFinish(state);
|
||||
state.timer.elapsed = 0;
|
||||
state.timer.elapsed = getElapsed(state);
|
||||
|
||||
if (state.rundown.actualStart === null) {
|
||||
state._startDayOffset = (findDayOffset(state.eventNow.timeStart, state.clock) + state.eventNow.dayOffset) as Day;
|
||||
@@ -520,6 +526,7 @@ export function addTime(amount: number) {
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime delays: over - under
|
||||
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
||||
@@ -575,7 +582,7 @@ export function update(): UpdateResult {
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime, needs up-to-date timer state
|
||||
const { absolute, relative } = getRuntimeOffset(runtimeState);
|
||||
|
||||
@@ -83,7 +83,7 @@ export type OntimeActionKey =
|
||||
| OntimeMessageSet
|
||||
| OntimeMessageSecondary;
|
||||
|
||||
export const ontimeActionKeyValues = [
|
||||
export const ontimeActionKeyValues: string[] = [
|
||||
...ontimeAuxTriggerAction,
|
||||
...ontimePlaybackAction,
|
||||
...ontimeAuxSetAction,
|
||||
|
||||
@@ -12,4 +12,4 @@ export enum TimerLifeCycle {
|
||||
|
||||
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
||||
|
||||
export const timerLifecycleValues = Object.keys(TimerLifeCycle);
|
||||
export const timerLifecycleValues: string[] = Object.keys(TimerLifeCycle);
|
||||
|
||||
@@ -10,7 +10,7 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
addedTime: 0,
|
||||
current: null, // changes on every update
|
||||
duration: null, // only changes if event changes
|
||||
elapsed: null, // changes on every update
|
||||
elapsed: null, // active time from start, changes on every update
|
||||
expectedFinish: null, // change can only be initiated by user, can roll over midnight
|
||||
phase: TimerPhase.None, // can change on update or user action
|
||||
playback: Playback.Stop, // change initiated by user
|
||||
|
||||
@@ -21,7 +21,7 @@ export type TimerState = {
|
||||
current: MaybeNumber;
|
||||
/** Total duration of the running event */
|
||||
duration: MaybeNumber;
|
||||
/** Time elapsed since the timer started */
|
||||
/** Active time elapsed since the timer started */
|
||||
elapsed: MaybeNumber;
|
||||
/** Timestamp of the expected finish time */
|
||||
expectedFinish: MaybeNumber;
|
||||
|
||||
@@ -16,6 +16,7 @@ export const langEn = {
|
||||
'common.no_data': 'No data',
|
||||
'countdown.ended': 'Event ended at',
|
||||
'countdown.running': 'Event running',
|
||||
'countdown.group_running': 'Event in group running',
|
||||
'countdown.loaded': 'Event loaded',
|
||||
'countdown.select_event': 'Select an event to follow',
|
||||
'countdown.to_start': 'Time to start',
|
||||
|
||||
Generated
+701
-241
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -6,8 +6,9 @@ catalog:
|
||||
'@types/node': 22.19.11
|
||||
rimraf: 6.0.1
|
||||
ts-essentials: 10.1.1
|
||||
typescript: 6.0.2
|
||||
typescript: 7.0.2
|
||||
vitest: 4.0.17
|
||||
zod: 4.4.3
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
|
||||
Reference in New Issue
Block a user