mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3134e5027a | |||
| 94024a7cdf | |||
| 9a6feed49b | |||
| c6cf9f2bf7 | |||
| 2f73303554 | |||
| 96943ca528 | |||
| 8cfffd32c7 | |||
| 24a606a246 | |||
| c322028821 | |||
| 9f40d962ea | |||
| 1e851ae40d | |||
| 2bcda2dfa4 | |||
| e2a2ea1d24 | |||
| 5be596b83e | |||
| ab15a65585 | |||
| 65ef442a9d | |||
| 71a7f26493 | |||
| b850a51204 | |||
| 0094c15da2 | |||
| e7d62f44e7 | |||
| 3c8d7e51f5 | |||
| 3da3950fce | |||
| ac3694ce4d | |||
| 41a09bf5c3 | |||
| 5dbe7afae8 | |||
| 6633cfc869 | |||
| d127367df9 | |||
| b21ec41ffd | |||
| 66292bc0cf | |||
| b946b245c2 | |||
| c238147b75 | |||
| 5f33c993f6 | |||
| 0e6d6cd416 | |||
| 199dea5df6 | |||
| 5a3ea9f7d0 | |||
| 9ae49a7175 | |||
| d30d9cfd4e | |||
| a0e7b9b665 | |||
| 1dcb7fb08d | |||
| a7e84b9903 | |||
| 3bebbf9ce9 | |||
| ab8da07518 |
@@ -20,13 +20,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -17,13 +17,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
@@ -15,14 +15,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -54,14 +58,18 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Get pnpm store directory
|
||||
run: echo "PNPM_STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.PNPM_STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,18 +82,18 @@ jobs:
|
||||
- name: Get installed Playwright version
|
||||
run: echo "PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --parseable | cut -s -d '@' -f3 | cut -d '/' -f1)" >> $GITHUB_ENV
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- run: npx playwright install --with-deps
|
||||
- run: pnpm playwright install --with-deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
run: pnpm playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm e2e
|
||||
|
||||
@@ -54,3 +54,6 @@ ontime-data/
|
||||
|
||||
# temporary write files
|
||||
**.tmp
|
||||
|
||||
# Claude Code metadata
|
||||
.claude/
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
ARG NODE_VERSION=22.15.1
|
||||
ARG NODE_VERSION=22.22.2
|
||||
FROM node:${NODE_VERSION}-alpine
|
||||
|
||||
# Set environment variables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
+17
-18
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.3.0",
|
||||
"@base-ui/react": "1.6.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -12,28 +12,27 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/open-sans": "^5.2.6",
|
||||
"@fontsource/open-sans": "^5.2.7",
|
||||
"@mantine/hooks": "^8.3.7",
|
||||
"@sentry/react": "^10.2.0",
|
||||
"@sentry/react": "^10.59.0",
|
||||
"@table-nav/react": "^0.0.7",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@tanstack/react-query-devtools": "^5.85.9",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.10",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "^1.18.0",
|
||||
"csv-stringify": "^6.6.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.2.7",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-hook-form": "^7.72.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-router": "^7.11.0",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"web-vitals": "^5.1.0",
|
||||
"zustand": "^5.0.9"
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-icons": "5.6.0",
|
||||
"react-router": "^8.0.1",
|
||||
"react-virtuoso": "^4.18.7",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
@@ -68,6 +67,7 @@
|
||||
"@vitejs/plugin-legacy": "^8.0.1",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"sass": "^1.57.1",
|
||||
@@ -75,7 +75,6 @@
|
||||
"vite": "8.0.1",
|
||||
"vite-plugin-compression2": "2.5.1",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,19 +34,17 @@ export async function fetchCurrentRundown(options?: RequestOptions): Promise<Run
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
/**
|
||||
* HTTP request to fetch all entries in the given rundown
|
||||
*/
|
||||
export async function fetchRundown(rundownId: RundownId, options?: RequestOptions): Promise<Rundown> {
|
||||
const res = await axios.get(`${rundownPath}/${rundownId}`, { signal: options?.signal });
|
||||
if (!isValidRundown(res.data)) {
|
||||
throw new Error('Invalid rundown payload');
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,3 +195,16 @@ export async function requestDeleteAll(rundownId: RundownId): Promise<AxiosRespo
|
||||
}
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
function isValidRundown(x: any): x is Rundown {
|
||||
return (
|
||||
x &&
|
||||
typeof x === 'object' &&
|
||||
typeof x.id === 'string' &&
|
||||
Array.isArray(x.order) &&
|
||||
Array.isArray(x.flatOrder) &&
|
||||
x.entries &&
|
||||
typeof x.entries === 'object' &&
|
||||
typeof x.revision === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo,
|
||||
|
||||
interface UseReactiveTextInputReturn {
|
||||
value: string;
|
||||
onChange: (event: ChangeEvent) => void;
|
||||
onBlur: (event: ChangeEvent) => void;
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
onBlur: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export default function useReactiveTextInput(
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
submitOnTab?: boolean;
|
||||
onCancelUpdate?: () => void;
|
||||
onTabCancel?: () => void;
|
||||
allowSubmitSameValue?: boolean;
|
||||
allowKeyboardNavigation?: boolean;
|
||||
},
|
||||
@@ -23,6 +25,10 @@ export default function useReactiveTextInput(
|
||||
const [text, setText] = useState<string>(initialText);
|
||||
// track whether we are submitting via a submit key (eg enter) and avoid submitting again on blur
|
||||
const isKeyboardSubmitting = useRef(false);
|
||||
// track escape to prevent the subsequent blur from submitting
|
||||
const isEscaping = useRef(false);
|
||||
// track tab-out to prevent blur from submitting (used by timer cells)
|
||||
const isTabbing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof initialText === 'undefined') {
|
||||
@@ -78,6 +84,7 @@ export default function useReactiveTextInput(
|
||||
* @param {string} valueToSubmit
|
||||
*/
|
||||
const handleEscape = useCallback(() => {
|
||||
isEscaping.current = true;
|
||||
// No need to update if it hasn't changed
|
||||
setText(initialText);
|
||||
// force the text to be the initial value
|
||||
@@ -141,16 +148,30 @@ export default function useReactiveTextInput(
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// for cells that opt out of tab-submit, track tab-out without preventing navigation
|
||||
if (event.key === 'Tab' && options?.submitOnTab === false) {
|
||||
isTabbing.current = true;
|
||||
}
|
||||
|
||||
hotKeyHandler(event);
|
||||
};
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, text]);
|
||||
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
|
||||
|
||||
return {
|
||||
value: text,
|
||||
onChange: (event: ChangeEvent) => handleChange((event.target as HTMLInputElement).value),
|
||||
onBlur: (event: ChangeEvent) => {
|
||||
onChange: (event) => handleChange(event.target.value),
|
||||
onBlur: (event) => {
|
||||
if (isTabbing.current) {
|
||||
isTabbing.current = false;
|
||||
(options?.onTabCancel ?? options?.onCancelUpdate)?.();
|
||||
return;
|
||||
}
|
||||
if (isEscaping.current) {
|
||||
isEscaping.current = false;
|
||||
return;
|
||||
}
|
||||
if (!isKeyboardSubmitting.current) {
|
||||
handleSubmit((event.target as HTMLInputElement).value);
|
||||
handleSubmit(event.target.value);
|
||||
}
|
||||
},
|
||||
onKeyDown: keyHandler,
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
padding-top: 10vh;
|
||||
}
|
||||
|
||||
.empty {
|
||||
width: 100%;
|
||||
opacity: 0.8;
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import EmptyImage from '../../../assets/images/empty.svg?react';
|
||||
import { SupportedEntry } from 'ontime-types';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import Button from '../buttons/Button';
|
||||
import Empty from './Empty';
|
||||
|
||||
import style from './EmptyTableBody.module.scss';
|
||||
|
||||
interface EmptyTableBodyProps {
|
||||
text: string;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
export default function EmptyTableBody({ text }: EmptyTableBodyProps) {
|
||||
export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const text = getLocalizedString('common.no_data');
|
||||
return (
|
||||
<tbody className={style.emptyContainer}>
|
||||
<tr>
|
||||
<td colSpan={99} className={style.emptyCell}>
|
||||
<EmptyImage className={style.empty} />
|
||||
{text && <span className={style.text}>{text}</span>}
|
||||
<Empty injectedStyles={{ marginTop: '5vh' }} />
|
||||
<span className={style.text}>{text}</span>
|
||||
{handleAddNew && (
|
||||
<div className={style.inline}>
|
||||
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
|
||||
<IoAdd />
|
||||
Create Event
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
renameRundown,
|
||||
} from '../api/rundown';
|
||||
|
||||
//TODO: make suspends so we don't have to deal with no value all over
|
||||
/**
|
||||
* Project rundowns
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, getRundownQueryKey } from '../api/constants';
|
||||
import { fetchCurrentRundown } from '../api/rundown';
|
||||
import { fetchCurrentRundown, fetchRundown } from '../api/rundown';
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { ExtendedEntry, getFlatRundownMetadata, getRundownMetadata } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
@@ -20,7 +20,11 @@ const cachedRundownPlaceholder: Rundown = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalised rundown data
|
||||
* Normalised rundown data for the currently loaded rundown.
|
||||
*
|
||||
* Bootstraps via the `/current` alias so the first paint is a single round-trip,
|
||||
* independent of the project rundown list. Once the loaded id is known, the
|
||||
* query key swaps to the id-keyed cache that is shared with `useRundownById`.
|
||||
*/
|
||||
export default function useRundown() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -34,12 +38,13 @@ export default function useRundown() {
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
// Seed the ID-based cache when fetching via the 'current' alias (bootstrap)
|
||||
// Seed the id-keyed cache when fetching via the bootstrap alias
|
||||
useEffect(() => {
|
||||
if (!data || loadedRundownId) return;
|
||||
queryClient.setQueryData(getRundownQueryKey(data.id), data);
|
||||
}, [data, loadedRundownId, queryClient]);
|
||||
|
||||
// Once we have the ID, drop the temporary current cache
|
||||
useEffect(() => {
|
||||
if (!loadedRundownId) return;
|
||||
queryClient.removeQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
@@ -114,3 +119,21 @@ export function useRundownAuxData() {
|
||||
}, [data]);
|
||||
return { data: filteredData, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides access to a specific rundown by ID.
|
||||
* When rundownId is null/undefined the query is disabled and returns the placeholder.
|
||||
*/
|
||||
export function useRundownById(rundownId: string | null | undefined) {
|
||||
const enabled = Boolean(rundownId);
|
||||
|
||||
const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
|
||||
queryKey: getRundownQueryKey(rundownId ?? ''),
|
||||
queryFn: ({ signal }) => fetchRundown(rundownId!, { signal }),
|
||||
enabled,
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { EntryId, Rundown } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useSelectedEventId } from '../hooks/useSocket';
|
||||
import { getFlatRundownMetadata, type ExtendedEntry } from '../utils/rundownMetadata';
|
||||
import { useProjectRundowns } from './useProjectRundowns';
|
||||
import { useRundownById } from './useRundown';
|
||||
|
||||
export type RundownSource = {
|
||||
rundownId: string | null;
|
||||
rundown: Rundown;
|
||||
flatRundown: ExtendedEntry[];
|
||||
status: string;
|
||||
selectedEventId: EntryId | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicitly scoped rundown data for views that may operate on a non-loaded rundown.
|
||||
*/
|
||||
export function useScopedRundown(rundownId: string | null): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
return useRundownSource(rundownId, projectRundowns.loaded || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded-rundown source for views that must follow the active runtime rundown.
|
||||
*/
|
||||
export function useLoadedRundownSource(): RundownSource {
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
const loadedRundownId = projectRundowns.loaded || null;
|
||||
return useRundownSource(loadedRundownId, loadedRundownId);
|
||||
}
|
||||
|
||||
function useRundownSource(rundownId: string | null, loadedRundownId: string | null): RundownSource {
|
||||
const isLoadedTarget = rundownId !== null && rundownId === loadedRundownId;
|
||||
const runtimeSelectedEventId = useSelectedEventId();
|
||||
const effectiveSelectedEventId = isLoadedTarget ? runtimeSelectedEventId : null;
|
||||
const { data: rundown, status } = useRundownById(rundownId);
|
||||
const flatRundown = useMemo(
|
||||
() => getFlatRundownMetadata(rundown, effectiveSelectedEventId),
|
||||
[effectiveSelectedEventId, rundown],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
rundownId,
|
||||
rundown,
|
||||
flatRundown,
|
||||
status,
|
||||
selectedEventId: effectiveSelectedEventId,
|
||||
}),
|
||||
[effectiveSelectedEventId, flatRundown, rundown, rundownId, status],
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
TimeStrategy,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
MILLIS_PER_SECOND,
|
||||
@@ -63,9 +64,16 @@ export type EventOptions = Partial<{
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries
|
||||
* Gather utilities for actions on entries in the loaded rundown.
|
||||
*/
|
||||
export const useEntryActions = () => {
|
||||
export const useEntryActions = () => useEntryActionsForRundown(undefined);
|
||||
|
||||
/**
|
||||
* Gather utilities for actions on entries in an explicitly selected rundown.
|
||||
*/
|
||||
export const useScopedEntryActions = (rundownId: string | null) => useEntryActionsForRundown(rundownId ?? '');
|
||||
|
||||
function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
linkPrevious,
|
||||
@@ -75,15 +83,16 @@ export const useEntryActions = () => {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
} = useEditorSettings();
|
||||
|
||||
const resolveCurrentRundownQueryKey = useCallback(() => {
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return getRundownQueryKey(loadedRundownId);
|
||||
if (scopedRundownId !== undefined) {
|
||||
return getRundownQueryKey(scopedRundownId);
|
||||
}
|
||||
return CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient]);
|
||||
const loadedRundownId = queryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
return loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
}, [queryClient, scopedRundownId]);
|
||||
|
||||
/**
|
||||
* Returns the currently loaded rundown
|
||||
@@ -232,6 +241,14 @@ export const useEntryActions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (inheritGroupColour && (isOntimeEvent(newEntry) || isOntimeMilestone(newEntry)) && !newEntry.colour) {
|
||||
const parentId = resolveInsertParent(rundownData, newEntry);
|
||||
const maybeParent = parentId ? rundownData.entries[parentId] : null;
|
||||
if (maybeParent && isOntimeGroup(maybeParent)) {
|
||||
newEntry.colour = maybeParent.colour;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await addEntryMutation([rundownId, newEntry]);
|
||||
} catch (error) {
|
||||
@@ -247,6 +264,7 @@ export const useEntryActions = () => {
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
defaultTimeStrategy,
|
||||
inheritGroupColour,
|
||||
addEntryMutation,
|
||||
],
|
||||
);
|
||||
@@ -1004,7 +1022,7 @@ export const useEntryActions = () => {
|
||||
updateTimer,
|
||||
],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to optimistically delete entries from client cache
|
||||
|
||||
@@ -12,8 +12,10 @@ type EditorSettingsStore = {
|
||||
defaultDangerTime: string;
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
inheritGroupColour: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
||||
setTimeStrategy: (timeStrategy: TimeStrategy) => void;
|
||||
setWarnTime: (warnTime: string) => void;
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
@@ -29,6 +31,7 @@ export const editorSettingsDefaults = {
|
||||
dangerTime: '00:01:00', // 60000 same as backend
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
inheritGroupColour: false,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
@@ -39,6 +42,7 @@ enum EditorSettingsKeys {
|
||||
DefaultDangerTime = 'ontime-default-danger-time',
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
@@ -59,6 +63,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.getItem(EditorSettingsKeys.DefaultEndAction),
|
||||
editorSettingsDefaults.endAction,
|
||||
),
|
||||
inheritGroupColour: booleanFromLocalStorage(
|
||||
EditorSettingsKeys.InheritGroupColour,
|
||||
editorSettingsDefaults.inheritGroupColour,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -97,5 +105,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.DefaultEndAction, String(defaultEndAction));
|
||||
return { defaultEndAction };
|
||||
}),
|
||||
setInheritGroupColour: (inheritGroupColour) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
||||
return { inheritGroupColour };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -40,6 +40,15 @@ describe('getRouteFromPreset()', () => {
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
const disabledPresets: URLPreset[] = [
|
||||
{
|
||||
enabled: false,
|
||||
alias: 'demopage',
|
||||
target: OntimeView.Timer,
|
||||
search: 'user=guest',
|
||||
options: {},
|
||||
},
|
||||
];
|
||||
|
||||
it('checks if the current location matches an enabled preset', () => {
|
||||
// we make the current location be the alias
|
||||
@@ -47,6 +56,21 @@ describe('getRouteFromPreset()', () => {
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('checks if the current location matches an enabled preset target', () => {
|
||||
const location = resolvePath('/timer');
|
||||
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
|
||||
});
|
||||
|
||||
it('does not redirect disabled presets', () => {
|
||||
const location = resolvePath('/demopage');
|
||||
expect(getRouteFromPreset(location, disabledPresets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when already on a preset path', () => {
|
||||
const location = resolvePath('/preset/demopage');
|
||||
expect(getRouteFromPreset(location, presets)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if the current location is the exact match of an unwrapped alias', () => {
|
||||
// we make the current location be the alias
|
||||
const location = resolvePath('/timer?user=guest&alias=demopage');
|
||||
@@ -81,6 +105,11 @@ describe('getRouteFromPreset()', () => {
|
||||
const location = resolvePath('/demopage?n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
|
||||
it('redirects stale unwrapped params back to the saved preset params while preserving feature params', () => {
|
||||
const location = resolvePath('/timer?user=admin&alias=demopage&n=1&token=123');
|
||||
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&n=1&token=123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cuesheet presets', () => {
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
Log,
|
||||
MaybeNumber,
|
||||
MessageTag,
|
||||
ProjectRundownsList,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
RuntimeStore,
|
||||
WsPacketToClient,
|
||||
WsPacketToServer,
|
||||
@@ -16,16 +14,17 @@ import {
|
||||
APP_SETTINGS,
|
||||
CLIENT_LIST,
|
||||
CSS_OVERRIDE,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
CUSTOM_FIELDS,
|
||||
PROJECT_DATA,
|
||||
CURRENT_RUNDOWN_QUERY_KEY,
|
||||
PROJECT_RUNDOWNS,
|
||||
REPORT,
|
||||
RUNDOWN,
|
||||
RUNTIME,
|
||||
TRANSLATION,
|
||||
URL_PRESETS,
|
||||
VIEW_SETTINGS,
|
||||
getRundownQueryKey,
|
||||
PROJECT_RUNDOWNS,
|
||||
} from '../api/constants';
|
||||
import { invalidateAllCaches } from '../api/utils';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
@@ -181,7 +180,7 @@ export const connectSocket = () => {
|
||||
}
|
||||
case MessageTag.Refetch: {
|
||||
// the refetch message signals that the rundown has changed in the server side
|
||||
const { target, revision } = payload;
|
||||
const { target, revision, rundownId } = payload;
|
||||
switch (target) {
|
||||
case RefetchKey.All:
|
||||
invalidateAllCaches();
|
||||
@@ -196,7 +195,7 @@ export const connectSocket = () => {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: REPORT });
|
||||
break;
|
||||
case RefetchKey.Rundown: {
|
||||
maybeInvalidateRundownCache(revision);
|
||||
maybeInvalidateRundownCache(revision, rundownId);
|
||||
break;
|
||||
}
|
||||
case RefetchKey.UrlPresets:
|
||||
@@ -214,6 +213,9 @@ export const connectSocket = () => {
|
||||
case RefetchKey.Settings:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: APP_SETTINGS });
|
||||
break;
|
||||
case RefetchKey.ProjectRundowns:
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_RUNDOWNS });
|
||||
break;
|
||||
default: {
|
||||
target satisfies never;
|
||||
break;
|
||||
@@ -232,34 +234,30 @@ export const connectSocket = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* When we receive a refetch message for the rundown
|
||||
* check which rundown needs to be invalidated
|
||||
*/
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber) {
|
||||
const loadedRundownId: string | undefined = (ontimeQueryClient.getQueryData(PROJECT_RUNDOWNS) as ProjectRundownsList)
|
||||
?.loaded;
|
||||
|
||||
const activeRundownQueryKey = loadedRundownId ? getRundownQueryKey(loadedRundownId) : CURRENT_RUNDOWN_QUERY_KEY;
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<Rundown>(activeRundownQueryKey);
|
||||
if (revision === cachedRundown?.revision) {
|
||||
export function maybeInvalidateRundownCache(revision: MaybeNumber, rundownId?: string) {
|
||||
if (!rundownId) {
|
||||
// we omit rundownId to signify invalidate all rundowns
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: activeRundownQueryKey, exact: true });
|
||||
// skip if we dont recognise the ID the revision is lower
|
||||
const queryKey = getRundownQueryKey(rundownId);
|
||||
const cachedRundown = ontimeQueryClient.getQueryData<{ revision: number }>(queryKey);
|
||||
|
||||
if (loadedRundownId) {
|
||||
// Keep bootstrap alias in sync with the ID-based cache
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
} else {
|
||||
// During bootstrap, loadedRundownId is not yet known.
|
||||
// Invalidate any ID-based rundown caches that may have been seeded early.
|
||||
ontimeQueryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === 'rundown' && query.queryKey[1] !== 'current',
|
||||
});
|
||||
if (revision === cachedRundown?.revision) {
|
||||
// we already have the latest change
|
||||
return;
|
||||
}
|
||||
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
|
||||
ontimeQueryClient.invalidateQueries({ queryKey, exact: true });
|
||||
|
||||
// keep current alias in sync with the ID-based cache
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<{ loaded: string }>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!loadedRundownId || loadedRundownId === rundownId) {
|
||||
ontimeQueryClient.invalidateQueries({ queryKey: CURRENT_RUNDOWN_QUERY_KEY, exact: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendSocket<T extends MessageTag | ApiActionTag>(
|
||||
|
||||
@@ -204,7 +204,7 @@ function isPresettableView(view: string): view is OntimeViewPresettable {
|
||||
/**
|
||||
* Check if current location is a preset path
|
||||
*/
|
||||
export function isPresetPath(location: Path): boolean {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
return segments[0] === 'preset';
|
||||
function isPresetPath(location: Path): boolean {
|
||||
const firstSegment = location.pathname.split('/').find(Boolean);
|
||||
return firstSegment === 'preset';
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ ul {
|
||||
background-color: $red-400;
|
||||
}
|
||||
}
|
||||
|
||||
&.split {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary {
|
||||
|
||||
@@ -42,16 +42,9 @@ interface PanelListItemProps {
|
||||
location?: string;
|
||||
}
|
||||
|
||||
function PanelListItem(props: PanelListItemProps) {
|
||||
const { panel, isSelected, location } = props;
|
||||
function PanelListItem({ panel, isSelected, location }: PanelListItemProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
|
||||
const classes = cx([
|
||||
style.primary,
|
||||
isSelected && style.active,
|
||||
panel.split && style.split,
|
||||
panel.highlight && style.highlight,
|
||||
]);
|
||||
const classes = cx([style.primary, isSelected && style.active, panel.highlight && style.highlight]);
|
||||
|
||||
return (
|
||||
<Fragment key={panel.id}>
|
||||
|
||||
+3
-4
@@ -89,10 +89,9 @@ export default function AutomationSettingsForm({
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<p>Control Ontime and share its data with external systems in your workflow.</p>
|
||||
<p>- Automations allow Ontime to send its data on lifecycle triggers.</p>
|
||||
<p>- OSC Input tells Ontime to listen to messages on the specific port.</p>
|
||||
<br />
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -57,11 +57,11 @@ export default function CustomFieldSettings() {
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Custom fields allow for additional information to be added to an event.
|
||||
<br />
|
||||
<br />
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
<span>Custom fields allow for additional information to be added to an event.</span>
|
||||
<span>
|
||||
To use custom fields as a data source in an{' '}
|
||||
<AppLink search='settings=automation__automations'>Automation</AppLink>, please note the generated key.
|
||||
</span>
|
||||
<ExternalLink href={customFieldsDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
@@ -52,8 +52,9 @@ export default function CustomViews() {
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
<br />
|
||||
<span>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
</span>
|
||||
External imports are not allowed, include all assets inside the html file.
|
||||
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IoPencilOutline,
|
||||
IoTrash,
|
||||
} from 'react-icons/io5';
|
||||
import { TbGhost3 } from 'react-icons/tb';
|
||||
|
||||
import { downloadAsExcel } from '../../../../common/api/excel';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -19,6 +20,7 @@ import { DropdownMenu } from '../../../../common/components/dropdown-menu/Dropdo
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { useMutateProjectRundowns, useProjectRundowns } from '../../../../common/hooks-query/useProjectRundowns';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { useDirectLinkToBackgroundEdit } from '../../../../views/cuesheet/useCuesheetRundownSelection';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import RundownRenameForm from './composite/RundownRenameForm';
|
||||
import { ManageRundownForm } from './ManageRundownForm';
|
||||
@@ -97,6 +99,8 @@ export default function ManageRundowns() {
|
||||
await downloadAsExcel(rundownId, title);
|
||||
};
|
||||
|
||||
const navigateToCuesheet = useDirectLinkToBackgroundEdit();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel.Section>
|
||||
@@ -153,7 +157,7 @@ export default function ManageRundowns() {
|
||||
</td>
|
||||
<td>
|
||||
<DropdownMenu
|
||||
render={<IconButton variant='ghosted-white' />}
|
||||
render={<IconButton variant='ghosted-white' data-testId='rundown_menu' />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
@@ -180,6 +184,12 @@ export default function ManageRundowns() {
|
||||
label: 'Duplicate',
|
||||
onClick: () => submitRundownDuplicate(id),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
icon: TbGhost3,
|
||||
label: 'Edit in cuesheet',
|
||||
onClick: () => navigateToCuesheet(id),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'destructive',
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function RundownDefaultSettings() {
|
||||
defaultDangerTime,
|
||||
defaultTimerType,
|
||||
defaultEndAction,
|
||||
inheritGroupColour,
|
||||
setDefaultDuration,
|
||||
setLinkPrevious,
|
||||
setTimeStrategy,
|
||||
@@ -23,6 +24,7 @@ export default function RundownDefaultSettings() {
|
||||
setDangerTime,
|
||||
setDefaultTimerType,
|
||||
setDefaultEndAction,
|
||||
setInheritGroupColour,
|
||||
} = useEditorSettings((state) => state);
|
||||
|
||||
const durationInMs = parseUserTime(defaultDuration);
|
||||
@@ -44,6 +46,13 @@ export default function RundownDefaultSettings() {
|
||||
/>
|
||||
<Switch size='large' checked={linkPrevious} onCheckedChange={setLinkPrevious} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Inherit group colour'
|
||||
description='Whether new events and milestones inherit the colour of their parent group'
|
||||
/>
|
||||
<Switch size='large' checked={inheritGroupColour} onCheckedChange={setInheritGroupColour} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='Timer strategy'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { generateUrl } from '../../../../common/api/session';
|
||||
import CopyTag from '../../../../common/components/copy-tag/CopyTag';
|
||||
import useInfo from '../../../../common/hooks-query/useInfo';
|
||||
import { isOntimeCloud, serverURL } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function McpSection() {
|
||||
const { data: infoData } = useInfo();
|
||||
const [mcpEndpointUrl, setMcpEndpointUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const baseUrl = isOntimeCloud
|
||||
? serverURL
|
||||
: infoData.networkInterfaces.length > 0
|
||||
? `http://${infoData.networkInterfaces[0].address}:${infoData.serverPort}`
|
||||
: serverURL;
|
||||
|
||||
generateUrl({ baseUrl, path: 'mcp', authenticate: true, lockConfig: false, lockNav: false })
|
||||
.then(setMcpEndpointUrl)
|
||||
.catch(() => {
|
||||
setMcpEndpointUrl('');
|
||||
});
|
||||
}, [infoData]);
|
||||
|
||||
const mcpClientConfig = mcpEndpointUrl
|
||||
? JSON.stringify({ mcpServers: { ontime: { url: mcpEndpointUrl } } }, null, 2)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>MCP Server</Panel.SubHeader>
|
||||
<Panel.Paragraph>Connect any MCP-compatible AI agent to Ontime using the endpoint below.</Panel.Paragraph>
|
||||
<Panel.Divider />
|
||||
<Panel.Field title='Endpoint URL' description='Add this URL to your MCP client settings' />
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpEndpointUrl}>{mcpEndpointUrl}</CopyTag>}
|
||||
<Panel.Divider />
|
||||
<Panel.Field
|
||||
title='Client configuration snippet'
|
||||
description='Paste this into your AI agent settings under "mcpServers"'
|
||||
/>
|
||||
{mcpEndpointUrl && <CopyTag copyValue={mcpClientConfig}>{mcpClientConfig}</CopyTag>}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import McpSection from './McpSection';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
import ViewSettings from './ViewSettings';
|
||||
@@ -13,6 +14,7 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
|
||||
return (
|
||||
@@ -30,6 +32,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={customViewsRef}>
|
||||
<CustomViews />
|
||||
</div>
|
||||
<div ref={mcpRef}>
|
||||
<McpSection />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
|
||||
+1
@@ -20,4 +20,5 @@
|
||||
border: 1px solid $gray-1100;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export type SettingsOption = {
|
||||
id: string;
|
||||
label: string;
|
||||
secondary?: Readonly<SettingsOption[]>;
|
||||
split?: boolean;
|
||||
highlight?: string;
|
||||
};
|
||||
|
||||
@@ -20,13 +19,13 @@ const staticOptions = [
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__port', label: 'Server port' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
...(isDocker ? [] : [{ id: 'settings__port', label: 'Server port' }]),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'project',
|
||||
label: 'Project',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'project__create', label: 'Create...' },
|
||||
{ id: 'project__list', label: 'Manage projects' },
|
||||
@@ -46,7 +45,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'automation__settings', label: 'Automation settings' },
|
||||
{ id: 'automation__automations', label: 'Manage automations' },
|
||||
@@ -56,7 +54,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'sharing',
|
||||
label: 'Sharing and reporting',
|
||||
split: true,
|
||||
secondary: [
|
||||
{ id: 'sharing__presets', label: 'URL Presets' },
|
||||
{
|
||||
@@ -69,7 +66,6 @@ const staticOptions = [
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
split: true,
|
||||
secondary: [
|
||||
{
|
||||
id: 'network__log',
|
||||
@@ -84,12 +80,10 @@ const staticOptions = [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'About',
|
||||
split: true,
|
||||
},
|
||||
{
|
||||
id: 'shutdown',
|
||||
label: 'Shutdown',
|
||||
split: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -103,18 +97,12 @@ export function useAppSettingsMenu() {
|
||||
|
||||
const options: Readonly<SettingsOption[]> = useMemo(
|
||||
() =>
|
||||
staticOptions.map((option) => ({
|
||||
...option,
|
||||
// if we are in docker don't show the port option
|
||||
secondary:
|
||||
'secondary' in option
|
||||
? isDocker && option.id === 'settings'
|
||||
? [...option.secondary.filter(({ id }) => id !== 'settings__port')]
|
||||
: [...option.secondary]
|
||||
: undefined,
|
||||
staticOptions.map((option) =>
|
||||
// if there is an update then highlight the about setting
|
||||
highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined,
|
||||
})),
|
||||
option.id === 'about' && data.hasUpdates
|
||||
? Object.assign({}, option, { highlight: 'New version available' })
|
||||
: option,
|
||||
),
|
||||
[data],
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
||||
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
|
||||
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useLoadedRundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useIsSmallDevice } from '../../common/hooks/useIsSmallDevice';
|
||||
import { handleLinks } from '../../common/utils/linkUtils';
|
||||
@@ -104,6 +105,8 @@ interface RundownRootProps {
|
||||
}
|
||||
|
||||
function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: RundownRootProps) {
|
||||
const source = useLoadedRundownSource();
|
||||
|
||||
return (
|
||||
<div className={style.rundownRoot}>
|
||||
{isSmallDevice ? (
|
||||
@@ -112,7 +115,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
|
||||
<RundownHeader isExtracted={isExtracted} viewMode={viewMode} setViewMode={setViewMode} />
|
||||
)}
|
||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||
{viewMode === RundownViewMode.Table && <EntryEditModal rundown={source.rundown} />}
|
||||
<RenumberCuesDialog />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OntimeEntry, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { OntimeEntry, Rundown, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import useRundown from '../../../common/hooks-query/useRundown';
|
||||
import EventEditor from './EventEditor';
|
||||
import GroupEditor from './GroupEditor';
|
||||
import MilestoneEditor from './MilestoneEditor';
|
||||
@@ -10,19 +9,18 @@ import style from './EntryEditor.module.scss';
|
||||
|
||||
interface CuesheetEntryEditorProps {
|
||||
entryId: string;
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId }: CuesheetEntryEditorProps) {
|
||||
const { data } = useRundown();
|
||||
|
||||
export default function CuesheetEntryEditor({ entryId, rundown }: CuesheetEntryEditorProps) {
|
||||
const entry = useMemo<OntimeEntry | null>(() => {
|
||||
if (data.order.length === 0) {
|
||||
if (rundown.order.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = data.entries[entryId];
|
||||
const event = rundown.entries[entryId];
|
||||
return event ?? null;
|
||||
}, [entryId, data.order, data.entries]);
|
||||
}, [entryId, rundown.entries, rundown.order.length]);
|
||||
|
||||
if (isOntimeEvent(entry)) {
|
||||
return (
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Toolbar } from '@base-ui/react/toolbar';
|
||||
import type { Column } from '@tanstack/react-table';
|
||||
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import {
|
||||
ColumnSettings,
|
||||
ViewSettings,
|
||||
} from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings';
|
||||
import { usePersistedRundownOptions } from '../rundown.options';
|
||||
|
||||
import style from '../../../views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.module.scss';
|
||||
|
||||
interface EditorTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
export default function EditorTableSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: EditorTableSettingsProps) {
|
||||
const options = usePersistedRundownOptions();
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings}>
|
||||
<ViewSettings optionsStore={options} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
</Toolbar.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { memo, useEffect, useMemo } from 'react';
|
||||
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
|
||||
import useCustomFields from '../../../common/hooks-query/useCustomFields';
|
||||
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import { useEntryActions } from '../../../common/hooks/useEntryAction';
|
||||
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
|
||||
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
|
||||
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
|
||||
@@ -13,6 +16,8 @@ function RundownTable() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
const setPermissions = useCuesheetPermissions((state) => state.setPermissions);
|
||||
const { editorMode } = useEditorFollowMode();
|
||||
const source = useLoadedRundownSource();
|
||||
const actions = useEntryActions();
|
||||
|
||||
// Editor always has full permissions
|
||||
useEffect(() => {
|
||||
@@ -30,12 +35,14 @@ function RundownTable() {
|
||||
const isLoading = !customFields || customFieldStatus === 'pending';
|
||||
|
||||
return (
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<CuesheetDnd columns={columns} tableRoot='editor'>
|
||||
{isLoading ? (
|
||||
<EmptyPage text='Loading...' />
|
||||
) : (
|
||||
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { EntryId, MaybeNumber, ProjectRundownsList, Rundown, isOntimeEvent } fro
|
||||
import { MouseEvent } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { CURRENT_RUNDOWN_QUERY_KEY, PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { PROJECT_RUNDOWNS, getRundownQueryKey } from '../../common/api/constants';
|
||||
import { ontimeQueryClient } from '../../common/queryClient';
|
||||
import { isMacOS } from '../../common/utils/deviceUtils';
|
||||
|
||||
@@ -137,12 +137,9 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
}));
|
||||
|
||||
function getLoadedRundownData() {
|
||||
const loadedRundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (loadedRundownId) {
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(loadedRundownId));
|
||||
}
|
||||
|
||||
return ontimeQueryClient.getQueryData<Rundown>(CURRENT_RUNDOWN_QUERY_KEY);
|
||||
const rundownId = ontimeQueryClient.getQueryData<ProjectRundownsList>(PROJECT_RUNDOWNS)?.loaded;
|
||||
if (!rundownId) return undefined;
|
||||
return ontimeQueryClient.getQueryData<Rundown>(getRundownQueryKey(rundownId));
|
||||
}
|
||||
|
||||
export function getSelectionMode(event: MouseEvent): SelectionMode {
|
||||
|
||||
@@ -82,5 +82,5 @@ export const initializeSentry = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return Sentry.withSentryReactRouterV6Routing(Routes);
|
||||
return Sentry.withSentryReactRouterV7Routing(Routes);
|
||||
};
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
'table';
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.rundownSelect {
|
||||
min-width: min(20rem, calc(100vw - 6rem));
|
||||
}
|
||||
|
||||
@@ -4,28 +4,34 @@ import { IoApps } from 'react-icons/io5';
|
||||
import IconButton from '../../common/components/buttons/IconButton';
|
||||
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
|
||||
import { EntryActionsProvider } from '../../common/context/EntryActionsContext';
|
||||
import { useEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useScopedRundown } from '../../common/hooks-query/useScopedRundown';
|
||||
import { useScopedEntryActions } from '../../common/hooks/useEntryAction';
|
||||
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
|
||||
import { getIsNavigationLocked } from '../../externals';
|
||||
import CuesheetOverview from '../../features/overview/CuesheetOverview';
|
||||
import EntryEditModal from './cuesheet-edit-modal/EntryEditModal';
|
||||
import CuesheetProgress from './cuesheet-progress/CuesheetProgress';
|
||||
import CuesheetTableWrapper from './CuesheetTableWrapper';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID, useCuesheetRundownSelection } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
export default function CuesheetPage() {
|
||||
'use memo';
|
||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
||||
const entryActions = useEntryActions();
|
||||
const { selectedRundownId, loadedRundownId, setSelectedRundownId, projectRundowns } = useCuesheetRundownSelection();
|
||||
const source = useScopedRundown(selectedRundownId === FOLLOW_LOADED_RUNDOWN_ID ? loadedRundownId : selectedRundownId);
|
||||
|
||||
const actions = useScopedEntryActions(source.rundownId);
|
||||
|
||||
useWindowTitle('Cuesheet');
|
||||
|
||||
const isLocked = getIsNavigationLocked();
|
||||
|
||||
return (
|
||||
<EntryActionsProvider actions={entryActions}>
|
||||
<EntryActionsProvider actions={actions}>
|
||||
<NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />
|
||||
<EntryEditModal />
|
||||
<EntryEditModal rundown={source.rundown} />
|
||||
<div className={styles.tableWrapper} data-testid='cuesheet'>
|
||||
<CuesheetOverview>
|
||||
{!isLocked && (
|
||||
@@ -35,7 +41,13 @@ export default function CuesheetPage() {
|
||||
)}
|
||||
</CuesheetOverview>
|
||||
<CuesheetProgress />
|
||||
<CuesheetTableWrapper />
|
||||
<CuesheetTableWrapper
|
||||
source={source}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</div>
|
||||
</EntryActionsProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { MaybeString, ProjectRundown } from 'ontime-types';
|
||||
import { memo, use, useMemo } from 'react';
|
||||
|
||||
import Select from '../../common/components/select/Select';
|
||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||
import { PresetContext } from '../../common/context/PresetContext';
|
||||
import useCustomFields from '../../common/hooks-query/useCustomFields';
|
||||
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
|
||||
import { AppMode } from '../../ontimeConfig';
|
||||
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
|
||||
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
|
||||
import CuesheetTable from './cuesheet-table/CuesheetTable';
|
||||
import { useApplyCuesheetPolicy } from './useApplyCuesheetPolicy';
|
||||
import { FOLLOW_LOADED_RUNDOWN_ID } from './useCuesheetRundownSelection';
|
||||
|
||||
import styles from './CuesheetPage.module.scss';
|
||||
|
||||
interface CuesheetTableWrapperProps {
|
||||
source: RundownSource;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
export default memo(CuesheetTableWrapper);
|
||||
function CuesheetTableWrapper() {
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
function CuesheetTableWrapper({
|
||||
source,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
loadedRundownId,
|
||||
projectRundowns,
|
||||
}: CuesheetTableWrapperProps) {
|
||||
const preset = use(PresetContext);
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset);
|
||||
const isCurrentRundown = source.rundownId !== null && source.rundownId === loadedRundownId;
|
||||
const { cuesheetMode, setCuesheetMode } = useApplyCuesheetPolicy(preset, { canRunMode: isCurrentRundown });
|
||||
const { data: customFields, status: customFieldStatus } = useCustomFields();
|
||||
|
||||
const columns = useMemo(
|
||||
() => makeCuesheetColumns(customFields, cuesheetMode, preset),
|
||||
@@ -28,11 +50,66 @@ function CuesheetTableWrapper() {
|
||||
) : (
|
||||
<CuesheetTable
|
||||
columns={columns}
|
||||
source={source}
|
||||
cuesheetMode={cuesheetMode}
|
||||
tableRoot='cuesheet'
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
isCurrentRundown={isCurrentRundown}
|
||||
insertElement={
|
||||
<>
|
||||
<RundownSelect
|
||||
cuesheetMode={cuesheetMode}
|
||||
selectedRundownId={selectedRundownId}
|
||||
loadedRundownId={loadedRundownId}
|
||||
setSelectedRundownId={setSelectedRundownId}
|
||||
projectRundowns={projectRundowns}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CuesheetDnd>
|
||||
);
|
||||
}
|
||||
|
||||
interface RundownSelectProps {
|
||||
cuesheetMode: AppMode;
|
||||
selectedRundownId: MaybeString;
|
||||
loadedRundownId: string;
|
||||
setSelectedRundownId: (rundownId: string) => void;
|
||||
projectRundowns: ProjectRundown[];
|
||||
}
|
||||
|
||||
function RundownSelect({
|
||||
cuesheetMode,
|
||||
projectRundowns,
|
||||
loadedRundownId,
|
||||
selectedRundownId,
|
||||
setSelectedRundownId,
|
||||
}: RundownSelectProps) {
|
||||
'use memo';
|
||||
const options = projectRundowns.map(({ id, title }) => ({
|
||||
value: id,
|
||||
label: loadedRundownId === id ? `${title} (loaded)` : title,
|
||||
}));
|
||||
options.unshift({
|
||||
value: FOLLOW_LOADED_RUNDOWN_ID,
|
||||
label: 'Follow loaded', // TODO: Better wording and maybe icon? and translation
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.rundownSelect}>
|
||||
<Select
|
||||
value={selectedRundownId ?? undefined}
|
||||
options={options}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedRundownId(value);
|
||||
}
|
||||
}}
|
||||
disabled={cuesheetMode === AppMode.Run}
|
||||
fluid
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { getEffectiveCuesheetMode } from '../useApplyCuesheetPolicy';
|
||||
|
||||
describe('getEffectiveCuesheetMode()', () => {
|
||||
it('honors the stored mode when the user can change mode on the current rundown', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Edit);
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Run mode when the preset forbids mode changes', () => {
|
||||
const permissions = { canChangeMode: false };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Edit, { canRunMode: true })).toBe(AppMode.Run);
|
||||
});
|
||||
|
||||
it('forces Edit mode for background rundowns without changing the permission policy', () => {
|
||||
const permissions = { canChangeMode: true };
|
||||
|
||||
expect(getEffectiveCuesheetMode(permissions, AppMode.Run, { canRunMode: false })).toBe(AppMode.Edit);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
FOLLOW_LOADED_RUNDOWN_ID,
|
||||
getCuesheetRundownStorageKey,
|
||||
resolveSelectedRundownId,
|
||||
} from '../useCuesheetRundownSelection';
|
||||
|
||||
describe('useCuesheetRundownSelection helpers', () => {
|
||||
it('builds a project-scoped storage key', () => {
|
||||
expect(getCuesheetRundownStorageKey('http://localhost:4001', 'My Project')).toBe(
|
||||
'cuesheet-selected-rundown:http://localhost:4001:My Project',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the follow loaded rundown when the stored selection is missing', () => {
|
||||
expect(resolveSelectedRundownId('missing', new Set(['loaded', 'other']))).toBe(FOLLOW_LOADED_RUNDOWN_ID);
|
||||
});
|
||||
|
||||
it('keeps the stored selection when it still exists in the current project', () => {
|
||||
expect(resolveSelectedRundownId('other', new Set(['loaded', 'other']))).toBe('other');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import { Rundown } from 'ontime-types';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import CuesheetEntryEditor from '../../../features/rundown/entry-editor/CuesheetEventEditor';
|
||||
import { useEditModal } from './useEditModal';
|
||||
|
||||
interface EntryEditModalProps {
|
||||
rundown: Rundown;
|
||||
}
|
||||
|
||||
export default memo(EntryEditModal);
|
||||
function EntryEditModal() {
|
||||
function EntryEditModal({ rundown }: EntryEditModalProps) {
|
||||
const entryId = useEditModal((state) => state.selectedEntryId);
|
||||
const closeModal = useEditModal((state) => state.clearSelection);
|
||||
|
||||
@@ -19,7 +24,7 @@ function EntryEditModal() {
|
||||
onClose={closeModal}
|
||||
title='Edit entry'
|
||||
showCloseButton
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} />}
|
||||
bodyElements={<CuesheetEntryEditor entryId={entryId} rundown={rundown} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTableNav } from '@table-nav/react';
|
||||
import { ColumnDef, Table, getCoreRowModel, useReactTable } from '@tanstack/react-table';
|
||||
import { OntimeEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { OntimeEntry, SupportedEntry, TimeField, isOntimeDelay, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
|
||||
import { ComponentProps, ReactNode, memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ContextProp,
|
||||
ItemProps,
|
||||
@@ -14,21 +14,20 @@ import {
|
||||
import EmptyPage from '../../../common/components/state/EmptyPage';
|
||||
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useFlatRundownWithMetadata } from '../../../common/hooks-query/useRundown';
|
||||
import { useSelectedEventId } from '../../../common/hooks/useSocket';
|
||||
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
|
||||
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
|
||||
import EditorTableSettings from '../../../features/rundown/rundown-table/EditorTableSettings';
|
||||
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
|
||||
import { useEventSelection } from '../../../features/rundown/useEventSelection';
|
||||
import { AppMode } from '../../../ontimeConfig';
|
||||
import { usePersistedCuesheetOptions } from '../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../useTablePermissions';
|
||||
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
|
||||
import DelayRow from './cuesheet-table-elements/DelayRow';
|
||||
import EventRow from './cuesheet-table-elements/EventRow';
|
||||
import GroupRow from './cuesheet-table-elements/GroupRow';
|
||||
import MilestoneRow from './cuesheet-table-elements/MilestoneRow';
|
||||
import TableMenu from './cuesheet-table-menu/TableMenu';
|
||||
import CuesheetTableSettings from './cuesheet-table-settings/CuesheetTableSettings';
|
||||
import CuesheetTableHeaderToolbar from './cuesheet-table-settings/CuesheetTableHeaderToolbar';
|
||||
import { useColumnOrder, useColumnSizes, useColumnVisibility } from './useColumnManager';
|
||||
|
||||
import style from './CuesheetTable.module.scss';
|
||||
@@ -36,30 +35,41 @@ import style from './CuesheetTable.module.scss';
|
||||
type CuesheetTableBaseProps = {
|
||||
columns: ColumnDef<ExtendedEntry>[];
|
||||
cuesheetMode: AppMode;
|
||||
source: RundownSource;
|
||||
insertElement?: ReactNode;
|
||||
};
|
||||
|
||||
type EditorCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'editor';
|
||||
setCuesheetMode?: undefined;
|
||||
isCurrentRundown?: undefined;
|
||||
};
|
||||
|
||||
type ViewCuesheetTableProps = CuesheetTableBaseProps & {
|
||||
tableRoot: 'cuesheet';
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
type CuesheetTableProps = EditorCuesheetTableProps | ViewCuesheetTableProps;
|
||||
|
||||
export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCuesheetMode }: CuesheetTableProps) {
|
||||
const { data, status } = useFlatRundownWithMetadata();
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
export default function CuesheetTable({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
source,
|
||||
tableRoot,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
insertElement,
|
||||
}: CuesheetTableProps) {
|
||||
const { flatRundown, status, selectedEventId } = source;
|
||||
const { updateEntry, updateTimer, addEntry } = useEntryActionsContext();
|
||||
const canCreateEntries = useCuesheetPermissions((state) => state.canCreateEntries) && cuesheetMode === AppMode.Edit;
|
||||
|
||||
const useOptions = tableRoot === 'editor' ? usePersistedRundownOptions : usePersistedCuesheetOptions;
|
||||
const showDelayedTimes = useOptions((state) => state.showDelayedTimes);
|
||||
const hideTableSeconds = useOptions((state) => state.hideTableSeconds);
|
||||
const hideIndexColumn = useOptions((state) => state.hideIndexColumn);
|
||||
const optionsStore = useOptions();
|
||||
const { showDelayedTimes, hideTableSeconds, hideIndexColumn } = optionsStore;
|
||||
|
||||
const selectedEventId = useSelectedEventId();
|
||||
const cursor = useEventSelection((state) => state.cursor);
|
||||
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
|
||||
|
||||
@@ -70,7 +80,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
() => ({
|
||||
handleUpdate: (rowIndex: number, accessor: string, payload: string, isCustom = false) => {
|
||||
// check if value is the same
|
||||
const event = data[rowIndex];
|
||||
const event = flatRundown[rowIndex];
|
||||
|
||||
if (!event) {
|
||||
return;
|
||||
@@ -101,7 +111,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
hideIndexColumn,
|
||||
},
|
||||
}),
|
||||
[cuesheetMode, data, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
[cuesheetMode, flatRundown, hideIndexColumn, hideTableSeconds, showDelayedTimes, updateEntry, updateTimer],
|
||||
);
|
||||
|
||||
const { columnOrder, resetColumnOrder } = useColumnOrder(columns, tableRoot);
|
||||
@@ -109,7 +119,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
const { columnVisibility, setColumnVisibility } = useColumnVisibility(tableRoot);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: flatRundown,
|
||||
columns,
|
||||
columnResizeMode: 'onChange',
|
||||
state: {
|
||||
@@ -137,13 +147,13 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === selectedEventId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === selectedEventId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
virtuosoRef.current.scrollToIndex({ index: eventIndex, behavior: 'auto', align: 'start', offset: -50 });
|
||||
}, [cuesheetMode, data, selectedEventId]);
|
||||
}, [cuesheetMode, flatRundown, selectedEventId]);
|
||||
|
||||
// Provide an imperative scroll handler for explicit jumps (finder/keyboard)
|
||||
useEffect(() => {
|
||||
@@ -152,7 +162,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return;
|
||||
}
|
||||
|
||||
const eventIndex = data.findIndex((event) => event.id === entryId);
|
||||
const eventIndex = flatRundown.findIndex((event) => event.id === entryId);
|
||||
if (eventIndex === -1) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +175,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
return () => {
|
||||
setScrollHandler(null);
|
||||
};
|
||||
}, [data, setScrollHandler]);
|
||||
}, [flatRundown, setScrollHandler]);
|
||||
|
||||
/**
|
||||
* To improve performance on resizing, we memoise the column sizes
|
||||
@@ -194,8 +204,9 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
listeners,
|
||||
rows,
|
||||
table,
|
||||
handleAddNew: canCreateEntries ? (type: SupportedEntry) => addEntry({ type }) : undefined,
|
||||
}),
|
||||
[columnSizeVars, cursor, listeners, rows, table],
|
||||
[columnSizeVars, cursor, listeners, rows, table, addEntry, canCreateEntries],
|
||||
);
|
||||
|
||||
const computeItemKey = useCallback((_: number, item: ExtendedEntry) => item.id, []);
|
||||
@@ -217,7 +228,7 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
});
|
||||
}, [cuesheetMode, hideIndexColumn, table]);
|
||||
|
||||
const isLoading = !data || status === 'pending';
|
||||
const isLoading = !flatRundown || status === 'pending';
|
||||
|
||||
if (isLoading) {
|
||||
return <EmptyPage text='Loading...' />;
|
||||
@@ -225,26 +236,27 @@ export default function CuesheetTable({ columns, cuesheetMode, tableRoot, setCue
|
||||
|
||||
return (
|
||||
<>
|
||||
{tableRoot === 'editor' ? (
|
||||
<EditorTableSettings
|
||||
columns={allLeafColumns}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
) : (
|
||||
<CuesheetTableSettings
|
||||
columns={allLeafColumns}
|
||||
cuesheetMode={cuesheetMode}
|
||||
setCuesheetMode={setCuesheetMode}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
/>
|
||||
)}
|
||||
<CuesheetTableHeaderToolbar
|
||||
columns={allLeafColumns}
|
||||
optionsStore={optionsStore}
|
||||
handleResetResizing={resetColumnResizing}
|
||||
handleResetReordering={resetColumnOrder}
|
||||
handleClearToggles={setAllVisible}
|
||||
insertElement={insertElement}
|
||||
modeControls={
|
||||
tableRoot === 'cuesheet'
|
||||
? {
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
isCurrentRundown,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
showShare={tableRoot === 'cuesheet'}
|
||||
/>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
data={data}
|
||||
data={flatRundown}
|
||||
context={virtuosoContext}
|
||||
style={tableRoot === 'editor' ? { paddingLeft: '1rem' } : undefined}
|
||||
computeItemKey={computeItemKey}
|
||||
@@ -264,10 +276,13 @@ interface CuesheetVirtuosoContext {
|
||||
listeners: ReturnType<typeof useTableNav>['listeners'];
|
||||
rows: ReturnType<Table<ExtendedEntry>['getRowModel']>['rows'];
|
||||
table: Table<ExtendedEntry>;
|
||||
handleAddNew?: (type: SupportedEntry) => void;
|
||||
}
|
||||
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder() {
|
||||
return <EmptyTableBody text='No data in rundown' />;
|
||||
const EmptyPlaceholder = memo(function EmptyPlaceholder({
|
||||
context,
|
||||
}: TableProps & ContextProp<CuesheetVirtuosoContext>) {
|
||||
return <EmptyTableBody handleAddNew={context.handleAddNew} />;
|
||||
});
|
||||
|
||||
const CuesheetTableElement = memo(function CuesheetTableElement({
|
||||
|
||||
+6
@@ -49,6 +49,10 @@ function DurationInput({
|
||||
setIsEditing(false);
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
};
|
||||
const handleTabOut = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
// Tab moves focus naturally; do not steal it back to parent
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -95,8 +99,10 @@ function DurationInput({
|
||||
ref={inputRef}
|
||||
initialValue={timeString}
|
||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||
submitOnTab={false}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancelUpdate={handleFakeBlur}
|
||||
handleTabCancel={handleTabOut}
|
||||
/>
|
||||
) : (
|
||||
<TextLikeInput
|
||||
|
||||
+4
-3
@@ -30,15 +30,16 @@ function EditableImage({ initialValue, readOnly, updateValue }: EditableImagePro
|
||||
}
|
||||
};
|
||||
|
||||
if (!initialValue && readOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!initialValue) {
|
||||
return (
|
||||
<Input
|
||||
variant='ghosted'
|
||||
className={style.imageInput}
|
||||
fluid
|
||||
readOnly={readOnly}
|
||||
// we disable the field to prevent receiving focus
|
||||
disabled={readOnly}
|
||||
placeholder='Paste image URL'
|
||||
onBlur={(event) => handleUpdate(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
+17
-15
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, OntimeEntry, RGBColour, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties, useMemo } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -30,7 +30,8 @@ interface EventRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function EventRow({
|
||||
export default memo(EventRow);
|
||||
function EventRow({
|
||||
rowId,
|
||||
id,
|
||||
eventIndex,
|
||||
@@ -55,24 +56,25 @@ export default function EventRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
const { color, backgroundColor } = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
|
||||
const mutedText = colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 });
|
||||
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
|
||||
const accessible = getAccessibleColour(colour);
|
||||
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
|
||||
|
||||
const rowBgColour: string | undefined = useMemo(() => {
|
||||
let rowBgColour: string | undefined;
|
||||
if (isLoaded) {
|
||||
return '#087A27'; // $active-green
|
||||
rowBgColour = '#087A27'; // $active-green
|
||||
} else if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
return colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
|
||||
if (accessibleBg !== null) {
|
||||
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
return {
|
||||
rowBgColour,
|
||||
backgroundColor: accessible.backgroundColor,
|
||||
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
|
||||
};
|
||||
}, [colour, isLoaded]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -20,7 +20,8 @@ interface GroupRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function GroupRow({
|
||||
export default memo(GroupRow);
|
||||
function GroupRow({
|
||||
groupId,
|
||||
colour,
|
||||
rowId,
|
||||
|
||||
+9
-13
@@ -1,7 +1,7 @@
|
||||
import { Table, flexRender } from '@tanstack/react-table';
|
||||
import { EntryId, SupportedEntry } from 'ontime-types';
|
||||
import { colourToHex, cssOrHexToColour } from 'ontime-utils';
|
||||
import { CSSProperties } from 'react';
|
||||
import { CSSProperties, memo, useMemo } from 'react';
|
||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -25,7 +25,8 @@ interface MilestoneRowProps {
|
||||
hasCursor?: boolean;
|
||||
}
|
||||
|
||||
export default function MilestoneRow({
|
||||
export default memo(MilestoneRow);
|
||||
function MilestoneRow({
|
||||
entryId,
|
||||
isPast,
|
||||
parentBgColour,
|
||||
@@ -45,17 +46,12 @@ export default function MilestoneRow({
|
||||
|
||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||
|
||||
let rowBgColour: string | undefined;
|
||||
if (colour) {
|
||||
// the colour is user defined and might be invalid
|
||||
const accessibleBackgroundColor = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBackgroundColor !== null) {
|
||||
rowBgColour = colourToHex({
|
||||
...accessibleBackgroundColor,
|
||||
alpha: accessibleBackgroundColor.alpha * 0.25,
|
||||
});
|
||||
}
|
||||
}
|
||||
const rowBgColour = useMemo(() => {
|
||||
if (!colour) return undefined;
|
||||
const accessibleBg = cssOrHexToColour(getAccessibleColour(colour).backgroundColor);
|
||||
if (accessibleBg === null) return undefined;
|
||||
return colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
|
||||
}, [colour]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
|
||||
+14
-1
@@ -8,13 +8,24 @@ interface SingleLineCellProps {
|
||||
fieldId?: string;
|
||||
fieldLabel?: string;
|
||||
allowSubmitSameValue?: boolean;
|
||||
submitOnTab?: boolean;
|
||||
handleUpdate: (newValue: string) => void;
|
||||
handleCancelUpdate?: () => void;
|
||||
handleTabCancel?: () => void;
|
||||
}
|
||||
|
||||
const SingleLineCell = forwardRef(
|
||||
(
|
||||
{ initialValue, fieldId, fieldLabel, allowSubmitSameValue, handleUpdate, handleCancelUpdate }: SingleLineCellProps,
|
||||
{
|
||||
initialValue,
|
||||
fieldId,
|
||||
fieldLabel,
|
||||
allowSubmitSameValue,
|
||||
submitOnTab,
|
||||
handleUpdate,
|
||||
handleCancelUpdate,
|
||||
handleTabCancel,
|
||||
}: SingleLineCellProps,
|
||||
inputRef,
|
||||
) => {
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
@@ -25,7 +36,9 @@ const SingleLineCell = forwardRef(
|
||||
allowKeyboardNavigation: true,
|
||||
submitOnEnter: true, // single line should submit on enter
|
||||
submitOnCtrlEnter: true,
|
||||
submitOnTab,
|
||||
onCancelUpdate: handleCancelUpdate,
|
||||
onTabCancel: handleTabCancel,
|
||||
});
|
||||
|
||||
// expose a subset of the methods to the parent
|
||||
|
||||
@@ -50,6 +50,10 @@ function TimeInputDuration({
|
||||
setIsEditing(false);
|
||||
setTimeout(() => textRef.current?.focusParentElement()); // Immediate timeout to ensure state change takes place first
|
||||
};
|
||||
const handleTabOut = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
// Tab moves focus naturally; do not steal it back to parent
|
||||
}, []);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(newValue: string) => {
|
||||
@@ -96,8 +100,10 @@ function TimeInputDuration({
|
||||
ref={inputRef}
|
||||
initialValue={timeString}
|
||||
allowSubmitSameValue={!lockedValue} // if the value is not locked, submitting will lock the value
|
||||
submitOnTab={false}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancelUpdate={handleFakeBlur}
|
||||
handleTabCancel={handleTabOut}
|
||||
/>
|
||||
) : (
|
||||
<TextLikeInput
|
||||
|
||||
+72
-48
@@ -11,78 +11,92 @@ import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
||||
import PopoverContents from '../../../../common/components/popover/Popover';
|
||||
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { AppMode } from '../../../../ontimeConfig';
|
||||
import { CuesheetOptions, usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||
import { useCuesheetPermissions } from '../../useTablePermissions';
|
||||
import CuesheetShareModal from './CuesheetShareModal';
|
||||
|
||||
import style from './CuesheetTableSettings.module.scss';
|
||||
|
||||
interface CuesheetTableSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
type TableHeaderOptionsStore = {
|
||||
hideTableSeconds: boolean;
|
||||
hideIndexColumn: boolean;
|
||||
showDelayedTimes: boolean;
|
||||
hideDelays: boolean;
|
||||
setOption: <K extends keyof TableHeaderOptionValues>(key: K, value: TableHeaderOptionValues[K]) => void;
|
||||
};
|
||||
|
||||
type TableHeaderOptionValues = Pick<
|
||||
TableHeaderOptionsStore,
|
||||
'hideTableSeconds' | 'hideIndexColumn' | 'showDelayedTimes' | 'hideDelays'
|
||||
>;
|
||||
|
||||
type TableModeControls = {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
isCurrentRundown?: boolean;
|
||||
};
|
||||
|
||||
export interface ViewSettingsProps {
|
||||
optionsStore: CuesheetOptions;
|
||||
}
|
||||
|
||||
export interface ColumnSettingsProps {
|
||||
interface CuesheetTableHeaderToolbarProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
insertElement?: ReactNode;
|
||||
modeControls?: TableModeControls;
|
||||
showShare?: boolean;
|
||||
}
|
||||
|
||||
export default function CuesheetTableSettings({
|
||||
export default function CuesheetTableHeaderToolbar({
|
||||
columns,
|
||||
cuesheetMode,
|
||||
setCuesheetMode,
|
||||
optionsStore,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
handleClearToggles,
|
||||
}: CuesheetTableSettingsProps) {
|
||||
insertElement,
|
||||
modeControls,
|
||||
showShare = false,
|
||||
}: CuesheetTableHeaderToolbarProps) {
|
||||
const canChangeMode = useCuesheetPermissions((state) => state.canChangeMode);
|
||||
const canShare = useCuesheetPermissions((state) => state.canShare);
|
||||
const options = usePersistedCuesheetOptions();
|
||||
|
||||
const toggleCuesheetMode = (mode: AppMode[]) => {
|
||||
// we need to stop user from deselecting a mode
|
||||
const newValue = mode.at(0);
|
||||
if (!newValue) return;
|
||||
setCuesheetMode(newValue);
|
||||
if (!newValue || !modeControls) return;
|
||||
modeControls.setCuesheetMode(newValue);
|
||||
};
|
||||
|
||||
const isBackground = !(modeControls?.isCurrentRundown ?? true);
|
||||
|
||||
return (
|
||||
<Toolbar.Root className={style.tableSettings}>
|
||||
<ViewSettings optionsStore={options} />
|
||||
<Toolbar.Root className={style.tableSettings} data-background-rundown={isBackground}>
|
||||
<ViewSettings optionsStore={optionsStore} />
|
||||
<ColumnSettings
|
||||
columns={columns}
|
||||
handleResetResizing={handleResetResizing}
|
||||
handleResetReordering={handleResetReordering}
|
||||
handleClearToggles={handleClearToggles}
|
||||
/>
|
||||
{canChangeMode && (
|
||||
<ToggleGroup
|
||||
value={[cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={cx([style.group, style.apart])}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
{modeControls && canChangeMode && (
|
||||
<div className={style.apart}>
|
||||
{insertElement}
|
||||
<ToggleGroup
|
||||
value={[modeControls.cuesheetMode]}
|
||||
onValueChange={toggleCuesheetMode}
|
||||
className={style.group}
|
||||
disabled={!modeControls.isCurrentRundown}
|
||||
>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Run} className={style.radioButton}>
|
||||
Run
|
||||
</Toolbar.Button>
|
||||
<Toolbar.Button render={<Toggle />} value={AppMode.Edit} className={style.radioButton}>
|
||||
Edit
|
||||
</Toolbar.Button>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canShare && (
|
||||
{showShare && canShare && (
|
||||
<>
|
||||
<Editor.Separator orientation='vertical' />
|
||||
<CuesheetShareModal />
|
||||
@@ -92,9 +106,18 @@ export default function CuesheetTableSettings({
|
||||
);
|
||||
}
|
||||
|
||||
export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
const options = optionsStore;
|
||||
interface ViewSettingsProps {
|
||||
optionsStore: TableHeaderOptionsStore;
|
||||
}
|
||||
|
||||
interface ColumnSettingsProps {
|
||||
columns: Column<ExtendedEntry, unknown>[];
|
||||
handleResetResizing: () => void;
|
||||
handleResetReordering: () => void;
|
||||
handleClearToggles: () => void;
|
||||
}
|
||||
|
||||
function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
return (
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
@@ -115,15 +138,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
<Editor.Label className={style.sectionTitle}>Element visibility</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideTableSeconds}
|
||||
onCheckedChange={(checked) => options.setOption('hideTableSeconds', checked)}
|
||||
defaultChecked={optionsStore.hideTableSeconds}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideTableSeconds', checked)}
|
||||
/>
|
||||
Hide seconds in table
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideIndexColumn}
|
||||
onCheckedChange={(checked) => options.setOption('hideIndexColumn', checked)}
|
||||
defaultChecked={optionsStore.hideIndexColumn}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideIndexColumn', checked)}
|
||||
/>
|
||||
Hide index column
|
||||
</Editor.Label>
|
||||
@@ -133,15 +156,15 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
<Editor.Label className={style.sectionTitle}>Table Behaviour</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.showDelayedTimes}
|
||||
onCheckedChange={(checked) => options.setOption('showDelayedTimes', checked)}
|
||||
defaultChecked={optionsStore.showDelayedTimes}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('showDelayedTimes', checked)}
|
||||
/>
|
||||
Show delayed times
|
||||
</Editor.Label>
|
||||
<Editor.Label className={style.option}>
|
||||
<Checkbox
|
||||
defaultChecked={options.hideDelays}
|
||||
onCheckedChange={(checked) => options.setOption('hideDelays', checked)}
|
||||
defaultChecked={optionsStore.hideDelays}
|
||||
onCheckedChange={(checked) => optionsStore.setOption('hideDelays', checked)}
|
||||
/>
|
||||
Hide delay entries
|
||||
</Editor.Label>
|
||||
@@ -151,7 +174,7 @@ export function ViewSettings({ optionsStore }: ViewSettingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ColumnSettings({
|
||||
function ColumnSettings({
|
||||
columns,
|
||||
handleResetResizing,
|
||||
handleResetReordering,
|
||||
@@ -177,6 +200,7 @@ export function ColumnSettings({
|
||||
{columns.map((column) => {
|
||||
const columnHeader = column.columnDef.header;
|
||||
const visible = column.getIsVisible();
|
||||
|
||||
return (
|
||||
<Editor.Label key={`${column.id}-${visible}`} className={style.option}>
|
||||
<Checkbox defaultChecked={visible} onCheckedChange={column.toggleVisibility} />
|
||||
+28
-2
@@ -8,6 +8,24 @@
|
||||
gap: 1rem;
|
||||
font-size: $inner-section-text-size;
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&[data-background-rundown='true'] {
|
||||
background-color: rgba($ontime-color, 0.5);
|
||||
|
||||
.apart::before {
|
||||
content: 'BACKGROUND EDIT';
|
||||
color: $ui-white;
|
||||
animation: blinker 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blinker {
|
||||
50% {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
@@ -43,6 +61,9 @@
|
||||
|
||||
.apart {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/** styles should match RundownHeader.module.scss */
|
||||
@@ -70,12 +91,17 @@
|
||||
font-size: calc(1rem - 2px);
|
||||
font-weight: 600;
|
||||
|
||||
&[data-disabled] {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
background: transparent;
|
||||
outline: 1px solid $blue-500;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover:not([data-disabled]):not(:active) {
|
||||
background: $gray-1000;
|
||||
}
|
||||
|
||||
@@ -87,7 +113,7 @@
|
||||
background: $blue-700;
|
||||
color: $ui-white;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
&:hover:not([data-disabled]):not(:active) {
|
||||
background: $blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,33 @@ import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { sessionScope } from '../../externals';
|
||||
import { AppMode, sessionKeys } from '../../ontimeConfig';
|
||||
import { getCuesheetPermissionsPolicy } from './cuesheet.policies';
|
||||
import type { CuesheetPermissions } from './useTablePermissions';
|
||||
import { useCuesheetPermissions } from './useTablePermissions';
|
||||
|
||||
type CuesheetPolicyOptions = {
|
||||
// Run mode is only meaningful for the loaded rundown — background rundowns force Edit.
|
||||
canRunMode: boolean;
|
||||
};
|
||||
|
||||
export function getEffectiveCuesheetMode(
|
||||
permissions: Pick<CuesheetPermissions, 'canChangeMode'>,
|
||||
storedMode: AppMode,
|
||||
{ canRunMode }: CuesheetPolicyOptions,
|
||||
) {
|
||||
if (!permissions.canChangeMode) {
|
||||
return AppMode.Run;
|
||||
}
|
||||
|
||||
return canRunMode ? storedMode : AppMode.Edit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies cuesheet permissions to shared state and exposes the effective mode for the UI.
|
||||
*/
|
||||
export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||
export function useApplyCuesheetPolicy(
|
||||
preset: URLPreset | undefined,
|
||||
{ canRunMode }: CuesheetPolicyOptions,
|
||||
): {
|
||||
cuesheetMode: AppMode;
|
||||
setCuesheetMode: (mode: AppMode) => void;
|
||||
} {
|
||||
@@ -26,16 +47,14 @@ export function useApplyCuesheetPolicy(preset: URLPreset | undefined): {
|
||||
defaultValue: preset ? AppMode.Run : AppMode.Edit,
|
||||
});
|
||||
|
||||
const cuesheetMode = permissions.canChangeMode ? storedCuesheetMode : AppMode.Run;
|
||||
const cuesheetMode = getEffectiveCuesheetMode(permissions, storedCuesheetMode, { canRunMode });
|
||||
|
||||
const setCuesheetMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!permissions.canChangeMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permissions.canChangeMode || !canRunMode) return;
|
||||
setStoredCuesheetMode(mode);
|
||||
},
|
||||
[permissions.canChangeMode, setStoredCuesheetMode],
|
||||
[canRunMode, permissions.canChangeMode, setStoredCuesheetMode],
|
||||
);
|
||||
|
||||
// Keep the shared permissions store aligned with the active preset policy.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useSessionStorage } from '@mantine/hooks';
|
||||
import { startTransition, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useOrderedProjectList } from '../../common/hooks-query/useProjectList';
|
||||
import { useProjectRundowns } from '../../common/hooks-query/useProjectRundowns';
|
||||
import { serverURL } from '../../externals';
|
||||
|
||||
export const FOLLOW_LOADED_RUNDOWN_ID = '__follow-loaded__' as const;
|
||||
|
||||
export function getCuesheetRundownStorageKey(server: string, projectFilename: string) {
|
||||
return `cuesheet-selected-rundown:${server}:${projectFilename}`;
|
||||
}
|
||||
|
||||
export function resolveSelectedRundownId(storedSelectedRundownId: string | null, availableRundownIds: Set<string>) {
|
||||
if (storedSelectedRundownId && availableRundownIds.has(storedSelectedRundownId)) return storedSelectedRundownId;
|
||||
return FOLLOW_LOADED_RUNDOWN_ID;
|
||||
}
|
||||
|
||||
export function useCuesheetRundownSelection() {
|
||||
'use memo';
|
||||
|
||||
const { data: projectRundowns } = useProjectRundowns();
|
||||
const {
|
||||
data: { lastLoadedProject },
|
||||
} = useOrderedProjectList();
|
||||
const storageKey = useMemo(() => getCuesheetRundownStorageKey(serverURL, lastLoadedProject), [lastLoadedProject]);
|
||||
const [storedSelectedRundownId, setStoredSelectedRundownId] = useSessionStorage<string | null>({
|
||||
key: storageKey,
|
||||
defaultValue: FOLLOW_LOADED_RUNDOWN_ID,
|
||||
});
|
||||
|
||||
const availableRundownIds = new Set(projectRundowns.rundowns.map(({ id }) => id)).add(FOLLOW_LOADED_RUNDOWN_ID);
|
||||
const { loaded: loadedRundownId } = projectRundowns;
|
||||
|
||||
const selectedRundownId = resolveSelectedRundownId(storedSelectedRundownId, availableRundownIds);
|
||||
|
||||
return {
|
||||
loadedRundownId,
|
||||
selectedRundownId,
|
||||
projectRundowns: projectRundowns.rundowns,
|
||||
setSelectedRundownId: (rundownId: string) => {
|
||||
startTransition(() => {
|
||||
setStoredSelectedRundownId(rundownId);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function useDirectLinkToBackgroundEdit() {
|
||||
const {
|
||||
data: { lastLoadedProject },
|
||||
} = useOrderedProjectList();
|
||||
const navigate = useNavigate();
|
||||
const storageKey = getCuesheetRundownStorageKey(serverURL, lastLoadedProject);
|
||||
const [_, setStoredSelectedRundownId] = useSessionStorage<string | null>({ key: storageKey, defaultValue: null });
|
||||
|
||||
return useCallback(
|
||||
async (rundownId: string) => {
|
||||
await navigate('/cuesheet');
|
||||
startTransition(() => setStoredSelectedRundownId(rundownId));
|
||||
},
|
||||
[setStoredSelectedRundownId, navigate],
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,9 @@ $studio-idle: $red-1300;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
|
||||
margin-top: -0.5rem;
|
||||
margin-left: -0.5rem;
|
||||
}
|
||||
|
||||
.tick--active {
|
||||
|
||||
@@ -87,7 +87,7 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
dom: true,
|
||||
},
|
||||
build: {
|
||||
outDir: './build',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
+10
-11
@@ -2,21 +2,22 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
"@modelcontextprotocol/sdk": "^1.15.0",
|
||||
"cookie": "1.0.2",
|
||||
"cookie-parser": "1.4.7",
|
||||
"cors": "2.8.5",
|
||||
"cors": "2.8.6",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "5.1.0",
|
||||
"express-static-gzip": "3.0.0",
|
||||
"express-validator": "7.2.1",
|
||||
"fast-equals": "^5.0.1",
|
||||
"express": "5.2.1",
|
||||
"express-static-gzip": "3.0.1",
|
||||
"express-validator": "7.3.2",
|
||||
"fast-equals": "^6.0.0",
|
||||
"google-auth-library": "^9.4.2",
|
||||
"lowdb": "^7.0.1",
|
||||
"multer": "2.1.0",
|
||||
"multer": "2.2.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
@@ -24,17 +25,15 @@
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.9",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/cors": "2.8.19",
|
||||
"@types/express": "5.0.3",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/multer": "2.1.0",
|
||||
"@types/node": "catalog:",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"esbuild": "^0.24.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-essentials": "catalog:",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "catalog:",
|
||||
|
||||
@@ -262,6 +262,6 @@ export const socket = new SocketServer();
|
||||
/**
|
||||
* Utility function to notify clients that the REST data is stale
|
||||
*/
|
||||
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null) {
|
||||
socket.sendAsJson(MessageTag.Refetch, { target, revision });
|
||||
export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null, rundownId?: string) {
|
||||
socket.sendAsJson(MessageTag.Refetch, { target, revision, rundownId });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CustomField,
|
||||
EntryId,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { makeNewRundown } from '../../../models/dataModel.js';
|
||||
@@ -59,13 +61,24 @@ export function makeOntimeMilestone(patch: Partial<OntimeMilestone>): OntimeMile
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to create a rundown object
|
||||
* Utility to create a rundown object.
|
||||
* Derives flatOrder from order/entries when the patch does not provide one,
|
||||
* so test fixtures match the shape of a processed rundown.
|
||||
*/
|
||||
export function makeRundown(patch: Partial<Rundown>): Rundown {
|
||||
return {
|
||||
...makeNewRundown(),
|
||||
...patch,
|
||||
};
|
||||
const rundown = { ...makeNewRundown(), ...patch };
|
||||
if (!patch.flatOrder) {
|
||||
const flatOrder: EntryId[] = [];
|
||||
for (const id of rundown.order) {
|
||||
flatOrder.push(id);
|
||||
const entry = rundown.entries[id];
|
||||
if (isOntimeGroup(entry)) {
|
||||
flatOrder.push(...entry.entries);
|
||||
}
|
||||
}
|
||||
rundown.flatOrder = flatOrder;
|
||||
}
|
||||
return rundown;
|
||||
}
|
||||
|
||||
export function makeCustomField(patch: Partial<CustomField>): CustomField {
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('createTransaction', () => {
|
||||
expect(typeof commit).toBe('function');
|
||||
});
|
||||
|
||||
it('should return the updated data after commit is called and writes are scheduled', () => {
|
||||
it('should return the updated data after commit is called and writes are scheduled', async () => {
|
||||
const { rundown, customFields, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: true });
|
||||
rundown.title = 'Another Title';
|
||||
customFields['newField'] = {
|
||||
@@ -72,8 +72,7 @@ describe('createTransaction', () => {
|
||||
colour: 'blue',
|
||||
};
|
||||
|
||||
const updated = commit();
|
||||
vi.runAllTimers();
|
||||
const updated = await commit();
|
||||
|
||||
expect(updated.rundown.title).toBe('Another Title');
|
||||
expect(updated.customFields).toHaveProperty('newField');
|
||||
@@ -132,6 +131,23 @@ describe('processRundown()', () => {
|
||||
expect(initResult.entries['3'].type).toBe(SupportedEntry.Delay);
|
||||
});
|
||||
|
||||
it('produces order, flatOrder and applies delays to following events', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', 'delay', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 1000, duration: 1000 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 250 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 1000, timeEnd: 2000, duration: 1000 }),
|
||||
},
|
||||
});
|
||||
|
||||
const processed = processRundown(rundown, {});
|
||||
|
||||
expect(processed.order).toEqual(['1', 'delay', '2']);
|
||||
expect(processed.flatEntryOrder).toEqual(['1', 'delay', '2']);
|
||||
expect((processed.entries['2'] as OntimeEvent).delay).toBe(250);
|
||||
});
|
||||
|
||||
it('calculates delays versions of a given rundown', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2'],
|
||||
@@ -1622,6 +1638,34 @@ describe('rundownMutation.applyDelay()', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the passed rundown own order, not the loaded rundown metadata', () => {
|
||||
// The loaded rundown has completely different events
|
||||
const loadedRundown = makeRundown({
|
||||
order: ['loaded-delay', 'loaded-ev'],
|
||||
entries: {
|
||||
'loaded-delay': makeOntimeDelay({ id: 'loaded-delay', duration: 9999 }),
|
||||
'loaded-ev': makeOntimeEvent({ id: 'loaded-ev', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
},
|
||||
});
|
||||
rundownCache.init(loadedRundown, {});
|
||||
|
||||
// Background rundown is independent of the loaded one
|
||||
const bgRundown = makeRundown({
|
||||
order: ['bg-delay', 'bg-ev'],
|
||||
entries: {
|
||||
'bg-delay': makeOntimeDelay({ id: 'bg-delay', duration: 500 }),
|
||||
'bg-ev': makeOntimeEvent({ id: 'bg-ev', timeStart: 0, timeEnd: 200, duration: 200 }),
|
||||
},
|
||||
});
|
||||
|
||||
rundownMutation.applyDelay(bgRundown, bgRundown.entries['bg-delay'] as OntimeDelay);
|
||||
|
||||
// bg-ev must be shifted by its own delay (500), not influenced by the loaded rundown
|
||||
expect((bgRundown.entries['bg-ev'] as OntimeEvent).timeStart).toBe(500);
|
||||
// loaded-ev must be untouched
|
||||
expect((loadedRundown.entries['loaded-ev'] as OntimeEvent).timeStart).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rundownMutation.swap()', () => {
|
||||
|
||||
@@ -2,13 +2,11 @@ import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
deleteById,
|
||||
doesInvalidateMetadata,
|
||||
duplicateRundown,
|
||||
getIntegerAndFraction,
|
||||
hasChanges,
|
||||
makeDeepClone,
|
||||
@@ -223,25 +221,6 @@ describe('calculateDayOffset()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicateRundown', () => {
|
||||
it('duplicates a given rundown', () => {
|
||||
const demoRundown = demoDb.rundowns['default'];
|
||||
const title = 'Duplicated Rundown';
|
||||
const duplicatedRundown = duplicateRundown(demoRundown, title);
|
||||
|
||||
expect(duplicatedRundown).toMatchObject({
|
||||
title: title,
|
||||
entries: expect.any(Object),
|
||||
order: expect.any(Array),
|
||||
flatOrder: expect.any(Array),
|
||||
});
|
||||
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
|
||||
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
|
||||
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
|
||||
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeDeepClone()', () => {
|
||||
it('deep clones a group along with its nested entries', () => {
|
||||
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
|
||||
|
||||
@@ -74,6 +74,7 @@ let rundownMetadata: RundownMetadata = {
|
||||
let projectCustomFields: CustomFields = {};
|
||||
|
||||
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||
export const getCurrentRundownId = (): string => cachedRundown.id;
|
||||
export const getRundownMetadata = (): Readonly<RundownMetadata> => rundownMetadata;
|
||||
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||
export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cachedRundown.entries[entryId];
|
||||
@@ -81,56 +82,85 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
|
||||
type Transaction = {
|
||||
customFields: CustomFields;
|
||||
rundown: Rundown;
|
||||
rundownMetadata: Readonly<RundownMetadata>;
|
||||
|
||||
commit: (shouldProcess?: boolean) => {
|
||||
commit: (shouldProcess?: boolean) => Promise<{
|
||||
rundown: Readonly<Rundown>;
|
||||
rundownMetadata: Readonly<RundownMetadata>;
|
||||
rundownMetadata: Readonly<RundownMetadata> | null;
|
||||
customFields: Readonly<CustomFields>;
|
||||
revision: Readonly<number>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
type TransactionOptions = {
|
||||
mutableRundown?: boolean;
|
||||
mutableCustomFields?: boolean;
|
||||
/**
|
||||
* Target rundown ID. Defaults to the loaded rundown.
|
||||
* When targeting a non-loaded ("background") rundown, the cache and runtime
|
||||
* metadata are bypassed — the rundown is read from disk and persisted directly.
|
||||
*/
|
||||
rundownId?: string;
|
||||
};
|
||||
|
||||
export function createTransaction(options: TransactionOptions): Transaction {
|
||||
const rundown = options.mutableRundown ? structuredClone(cachedRundown) : cachedRundown;
|
||||
const targetId = options.rundownId ?? cachedRundown.id;
|
||||
const isLoaded = targetId === cachedRundown.id;
|
||||
const sourceRundown: Rundown = isLoaded ? cachedRundown : (getDataProvider().getRundown(targetId) as Rundown);
|
||||
const rundown = options.mutableRundown ? structuredClone(sourceRundown) : sourceRundown;
|
||||
const customFields = options.mutableCustomFields ? structuredClone(projectCustomFields) : projectCustomFields;
|
||||
|
||||
/**
|
||||
* Applies a mutated rundown to the cache
|
||||
* Applies a mutated rundown to its persistence target
|
||||
* @param shouldProcess - whether the rundown should be processed after the commit
|
||||
* Some edit mutations, and custom field changes do not require processing
|
||||
*/
|
||||
function commit(shouldProcess: boolean = true) {
|
||||
async function commit(shouldProcess: boolean = true) {
|
||||
let committedRundown: Readonly<Rundown> = isLoaded ? cachedRundown : rundown;
|
||||
let committedMetadata: RundownMetadata | null = isLoaded ? rundownMetadata : null;
|
||||
let committedRevision: number = sourceRundown.revision;
|
||||
|
||||
// if the rundown is mutable we persist the changes
|
||||
if (options.mutableRundown) {
|
||||
// update fields which are agnostic of whether the rundown is processed
|
||||
cachedRundown.revision = cachedRundown.revision + 1;
|
||||
cachedRundown.title = rundown.title;
|
||||
if (isLoaded) {
|
||||
// update fields which are agnostic of whether the rundown is processed
|
||||
cachedRundown.revision = cachedRundown.revision + 1;
|
||||
cachedRundown.title = rundown.title;
|
||||
|
||||
if (!shouldProcess) {
|
||||
// if we dont need to process, we just reassign the commit data to the cache
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
cachedRundown.flatOrder = rundown.flatOrder;
|
||||
if (!shouldProcess) {
|
||||
// if we dont need to process, we just reassign the commit data to the cache
|
||||
cachedRundown.entries = rundown.entries;
|
||||
cachedRundown.order = rundown.order;
|
||||
cachedRundown.flatOrder = rundown.flatOrder;
|
||||
} else {
|
||||
const processedData = processRundown(rundown, projectCustomFields, { mutate: true });
|
||||
// update the cache values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||
rundownMetadata = metadata;
|
||||
}
|
||||
|
||||
// persist after all mutations are applied
|
||||
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
committedRundown = cachedRundown;
|
||||
committedMetadata = rundownMetadata;
|
||||
committedRevision = cachedRundown.revision;
|
||||
} else {
|
||||
const processedData = processRundown(rundown, projectCustomFields);
|
||||
// update the cache values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||
rundownMetadata = metadata;
|
||||
// background rundown: process if requested, persist directly, no runtime metadata
|
||||
rundown.revision = rundown.revision + 1;
|
||||
if (shouldProcess) {
|
||||
const processedData = processRundown(rundown, projectCustomFields, { mutate: true });
|
||||
rundown.entries = processedData.entries;
|
||||
rundown.order = processedData.order;
|
||||
rundown.flatOrder = processedData.flatEntryOrder;
|
||||
}
|
||||
await getDataProvider().setRundown(targetId, rundown);
|
||||
committedRundown = rundown;
|
||||
committedRevision = rundown.revision;
|
||||
}
|
||||
|
||||
// persist after all mutations are applied
|
||||
getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||
}
|
||||
|
||||
// if the customFields are mutable we persist the changes
|
||||
@@ -138,21 +168,20 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// persist after reassignment
|
||||
getDataProvider().setCustomFields(projectCustomFields);
|
||||
await getDataProvider().setCustomFields(projectCustomFields);
|
||||
}
|
||||
|
||||
return {
|
||||
rundown: cachedRundown,
|
||||
rundownMetadata,
|
||||
rundown: committedRundown,
|
||||
rundownMetadata: committedMetadata,
|
||||
customFields: projectCustomFields,
|
||||
revision: cachedRundown.revision,
|
||||
revision: committedRevision,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customFields,
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
commit,
|
||||
};
|
||||
}
|
||||
@@ -287,11 +316,11 @@ function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry,
|
||||
* Mutates the given rundown
|
||||
*/
|
||||
function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
||||
const delayIndex = rundownMetadata.flatEntryOrder.indexOf(delay.id);
|
||||
const delayIndex = rundown.flatOrder.indexOf(delay.id);
|
||||
|
||||
// if the delay is empty, or the last element
|
||||
// there is nothing do apply
|
||||
if (delay.duration === 0 || delayIndex === rundownMetadata.flatEntryOrder.length - 1) {
|
||||
if (delay.duration === 0 || delayIndex === rundown.flatOrder.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,8 +333,8 @@ function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
||||
let lastEntry: OntimeEvent | null = null;
|
||||
let isFirstEvent = true;
|
||||
|
||||
for (let i = delayIndex + 1; i < rundownMetadata.flatEntryOrder.length; i++) {
|
||||
const currentId = rundownMetadata.flatEntryOrder[i];
|
||||
for (let i = delayIndex + 1; i < rundown.flatOrder.length; i++) {
|
||||
const currentId = rundown.flatOrder[i];
|
||||
const currentEntry = rundown.entries[currentId];
|
||||
|
||||
// we don't do operation on other event types
|
||||
@@ -587,6 +616,23 @@ export async function updateBackgroundRundown(rundownId: string, rundown: Rundow
|
||||
await getDataProvider().setRundown(rundownId, rundown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a rundown from disk and returns it in the processed shape used by clients.
|
||||
* Used for non-loaded rundowns — the loaded rundown is served directly from cache.
|
||||
*/
|
||||
export function getProcessedRundown(rundownId: string): Rundown {
|
||||
const stored = getDataProvider().getRundown(rundownId);
|
||||
const processed = processRundown(stored, getDataProvider().getCustomFields());
|
||||
return {
|
||||
id: stored.id,
|
||||
title: stored.title,
|
||||
entries: processed.entries,
|
||||
order: processed.order,
|
||||
flatOrder: processed.flatEntryOrder,
|
||||
revision: stored.revision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new custom field to the object and returns it
|
||||
*/
|
||||
@@ -665,7 +711,7 @@ export const customFieldMutation = {
|
||||
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
||||
const rundown = structuredClone(initialRundown);
|
||||
const customFields = structuredClone(initialCustomFields);
|
||||
const processedData = processRundown(rundown, customFields);
|
||||
const processedData = processRundown(rundown, customFields, { mutate: true });
|
||||
|
||||
// update the cache values
|
||||
cachedRundown.id = rundown.id;
|
||||
@@ -698,72 +744,57 @@ export const rundownCache = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility updates cache after a mutation
|
||||
* Handles calculating the rundown metadata
|
||||
* Computes derived rundown metadata (delays, gaps, group times, ordered lists).
|
||||
* By default the input rundown is not mutated — entries are cloned as they are processed.
|
||||
* Callers that own a writable rundown can pass `{ mutate: true }` to skip the per-entry clone.
|
||||
* @private should not be called outside of `rundown.dao.ts`, exported for testing
|
||||
*/
|
||||
export function processRundown(
|
||||
initialRundown: Readonly<Rundown>,
|
||||
customFields: Readonly<CustomFields>,
|
||||
options?: { mutate?: boolean },
|
||||
): ProcessedRundownMetadata {
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields);
|
||||
const { process, getMetadata } = makeRundownMetadata(customFields, options);
|
||||
|
||||
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||
// we assign a reference to the current entry, this will be mutated in place
|
||||
const currentEntryId = initialRundown.order[i];
|
||||
const currentEntry = initialRundown.entries[currentEntryId];
|
||||
if (!currentEntry) {
|
||||
continue;
|
||||
}
|
||||
const { processedEntry } = process(currentEntry, null);
|
||||
const currentEntry = initialRundown.entries[initialRundown.order[i]];
|
||||
if (!currentEntry) continue;
|
||||
|
||||
// if the event is a group, we process the nested entries
|
||||
// the code here is a copy of the processing of top level events
|
||||
if (isOntimeGroup(processedEntry)) {
|
||||
let groupStartTime = null;
|
||||
let groupEndTime = null;
|
||||
let isFirstLinked = false;
|
||||
const groupEvents: EntryId[] = [];
|
||||
processedEntry.duration = 0;
|
||||
const processedEntry = process(currentEntry, null);
|
||||
if (!isOntimeGroup(processedEntry)) continue;
|
||||
|
||||
// check if the group contains nested entries
|
||||
for (let j = 0; j < processedEntry.entries.length; j++) {
|
||||
const nestedEntryId = processedEntry.entries[j];
|
||||
const nestedEntry = initialRundown.entries[nestedEntryId];
|
||||
// process nested entries and recompute aggregate group fields
|
||||
let groupStartTime: number | null = null;
|
||||
let groupEndTime: number | null = null;
|
||||
let isFirstLinked = false;
|
||||
const groupEvents: EntryId[] = [];
|
||||
processedEntry.duration = 0;
|
||||
|
||||
if (!nestedEntry) {
|
||||
continue;
|
||||
}
|
||||
for (let j = 0; j < processedEntry.entries.length; j++) {
|
||||
const nestedEntry = initialRundown.entries[processedEntry.entries[j]];
|
||||
if (!nestedEntry) continue;
|
||||
|
||||
groupEvents.push(nestedEntry.id);
|
||||
const { processedEntry: processedNestedEntry } = process(nestedEntry, processedEntry.id);
|
||||
groupEvents.push(nestedEntry.id);
|
||||
const processedNestedEntry = process(nestedEntry, processedEntry.id);
|
||||
|
||||
// we dont extract metadata of skipped events,
|
||||
// if this is not a playable event there is nothing else to do
|
||||
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
|
||||
continue;
|
||||
}
|
||||
// skip metadata aggregation for non-playable nested entries
|
||||
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) continue;
|
||||
|
||||
// first start is always the first event
|
||||
if (groupStartTime === null) {
|
||||
groupStartTime = processedNestedEntry.timeStart;
|
||||
isFirstLinked = Boolean(processedNestedEntry.linkStart);
|
||||
}
|
||||
|
||||
// lastEntry is the event with the latest end time
|
||||
groupEndTime = processedNestedEntry.timeEnd;
|
||||
if (j > 0) {
|
||||
processedEntry.duration += processedNestedEntry.gap;
|
||||
}
|
||||
processedEntry.duration = processedEntry.duration + processedNestedEntry.duration;
|
||||
if (groupStartTime === null) {
|
||||
groupStartTime = processedNestedEntry.timeStart;
|
||||
isFirstLinked = Boolean(processedNestedEntry.linkStart);
|
||||
}
|
||||
|
||||
// update group metadata
|
||||
processedEntry.timeStart = groupStartTime;
|
||||
processedEntry.timeEnd = groupEndTime;
|
||||
processedEntry.isFirstLinked = isFirstLinked;
|
||||
processedEntry.entries = groupEvents;
|
||||
groupEndTime = processedNestedEntry.timeEnd;
|
||||
if (j > 0) {
|
||||
processedEntry.duration += processedNestedEntry.gap;
|
||||
}
|
||||
processedEntry.duration += processedNestedEntry.duration;
|
||||
}
|
||||
|
||||
processedEntry.timeStart = groupStartTime;
|
||||
processedEntry.timeEnd = groupEndTime;
|
||||
processedEntry.isFirstLinked = isFirstLinked;
|
||||
processedEntry.entries = groupEvents;
|
||||
}
|
||||
|
||||
return getMetadata();
|
||||
|
||||
@@ -205,10 +205,15 @@ export type ProcessedRundownMetadata = RundownMetadata & {
|
||||
|
||||
/**
|
||||
* Factory function to create a rundown metadata processor
|
||||
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
||||
*
|
||||
* @param customFields project custom fields used to sanitise entries
|
||||
* @param options.mutate when true, entries are mutated in place rather than cloned.
|
||||
* Callers must own (or have already cloned) the input rundown.
|
||||
* @returns process() - processes entries in order | getMetadata() returns the accumulated metadata
|
||||
*/
|
||||
export function makeRundownMetadata(customFields: CustomFields) {
|
||||
let rundownMeta: ProcessedRundownMetadata = {
|
||||
export function makeRundownMetadata(customFields: CustomFields, options?: { mutate?: boolean }) {
|
||||
const mutate = options?.mutate ?? false;
|
||||
const rundownMeta: ProcessedRundownMetadata = {
|
||||
totalDelay: 0,
|
||||
totalDuration: 0,
|
||||
totalDays: 0,
|
||||
@@ -228,13 +233,8 @@ export function makeRundownMetadata(customFields: CustomFields) {
|
||||
previousEntry: null,
|
||||
};
|
||||
|
||||
function process<T extends OntimeEntry>(
|
||||
entry: T,
|
||||
childOfGroup: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const data = processEntry(rundownMeta, customFields, entry, childOfGroup);
|
||||
rundownMeta = data.processedData;
|
||||
return data;
|
||||
function process<T extends OntimeEntry>(entry: T, childOfGroup: EntryId | null): T {
|
||||
return processEntry(rundownMeta, customFields, mutate ? entry : structuredClone(entry), childOfGroup);
|
||||
}
|
||||
|
||||
function getMetadata(): ProcessedRundownMetadata {
|
||||
@@ -245,20 +245,19 @@ export function makeRundownMetadata(customFields: CustomFields) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single entry and updates the rundown metadata
|
||||
* Processes a single entry, mutating both `entry` and `rundownMetadata` in place.
|
||||
* Returns the same `entry` reference for caller convenience.
|
||||
*/
|
||||
function processEntry<T extends OntimeEntry>(
|
||||
rundownMetadata: ProcessedRundownMetadata,
|
||||
customFields: CustomFields,
|
||||
entry: T,
|
||||
childOfGroup: EntryId | null,
|
||||
): { processedData: ProcessedRundownMetadata; processedEntry: T } {
|
||||
const processedData = { ...rundownMetadata };
|
||||
const currentEntry = structuredClone(entry);
|
||||
processedData.flatEntryOrder.push(currentEntry.id);
|
||||
): T {
|
||||
rundownMetadata.flatEntryOrder.push(entry.id);
|
||||
|
||||
if (isOntimeEvent(currentEntry)) {
|
||||
processedData.timedEventOrder.push(currentEntry.id);
|
||||
if (isOntimeEvent(entry)) {
|
||||
rundownMetadata.timedEventOrder.push(entry.id);
|
||||
|
||||
/**
|
||||
* 1.Checks that link can be established (ie, events exist and are valid)
|
||||
@@ -266,91 +265,91 @@ function processEntry<T extends OntimeEntry>(
|
||||
* The linked event is always the previous playable event
|
||||
* If no previous event exists, the link is removed
|
||||
*/
|
||||
if (currentEntry.linkStart) {
|
||||
if (processedData.previousEvent) {
|
||||
const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent);
|
||||
currentEntry.timeStart = timePatch.timeStart;
|
||||
currentEntry.timeEnd = timePatch.timeEnd;
|
||||
currentEntry.duration = timePatch.duration;
|
||||
if (entry.linkStart) {
|
||||
if (rundownMetadata.previousEvent) {
|
||||
const timePatch = getLinkedTimes(entry, rundownMetadata.previousEvent);
|
||||
entry.timeStart = timePatch.timeStart;
|
||||
entry.timeEnd = timePatch.timeEnd;
|
||||
entry.duration = timePatch.duration;
|
||||
} else {
|
||||
currentEntry.linkStart = false;
|
||||
entry.linkStart = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. handle custom fields - mutates currentEntry
|
||||
sanitiseCustomFields(customFields, currentEntry);
|
||||
// 2. handle custom fields - mutates entry
|
||||
sanitiseCustomFields(customFields, entry);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays as Day;
|
||||
currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
currentEntry.parent = childOfGroup;
|
||||
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
||||
entry.dayOffset = rundownMetadata.totalDays as Day;
|
||||
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
entry.parent = childOfGroup;
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(currentEntry)) {
|
||||
processedData.playableEventOrder.push(currentEntry.id);
|
||||
if (isPlayableEvent(entry)) {
|
||||
rundownMetadata.playableEventOrder.push(entry.id);
|
||||
|
||||
// first start is always the first event
|
||||
if (processedData.firstStart === null) {
|
||||
processedData.firstStart = currentEntry.timeStart;
|
||||
if (rundownMetadata.firstStart === null) {
|
||||
rundownMetadata.firstStart = entry.timeStart;
|
||||
}
|
||||
|
||||
// check if event is flagged
|
||||
if (currentEntry.flag) {
|
||||
processedData.flags.push(currentEntry.id);
|
||||
if (entry.flag) {
|
||||
rundownMetadata.flags.push(entry.id);
|
||||
}
|
||||
|
||||
currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent);
|
||||
entry.gap = getTimeFrom(entry, rundownMetadata.latestEvent);
|
||||
|
||||
if (currentEntry.gap === 0) {
|
||||
if (entry.gap === 0) {
|
||||
// event starts on previous finish, we add its duration
|
||||
processedData.totalDuration += currentEntry.duration;
|
||||
} else if (currentEntry.gap > 0) {
|
||||
rundownMetadata.totalDuration += entry.duration;
|
||||
} else if (entry.gap > 0) {
|
||||
// event has a gap, we add the gap and the duration
|
||||
processedData.totalDuration += currentEntry.gap + currentEntry.duration;
|
||||
} else if (currentEntry.gap < 0) {
|
||||
rundownMetadata.totalDuration += entry.gap + entry.duration;
|
||||
} else {
|
||||
// there is an overlap, we remove the overlap from the duration
|
||||
// ensuring that the sum is not negative (ie: fully overlapped events)
|
||||
// NOTE: we add the gap since it is a negative number
|
||||
processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
|
||||
rundownMetadata.totalDuration += Math.max(entry.duration + entry.gap, 0);
|
||||
}
|
||||
|
||||
// remove eventual gaps from the accumulated delay
|
||||
// we only affect positive delays (time forwards)
|
||||
if (processedData.totalDelay > 0 && currentEntry.gap > 0) {
|
||||
if (rundownMetadata.totalDelay > 0 && entry.gap > 0) {
|
||||
let correctedDelay = 0;
|
||||
// we need to separate the delay that is accumulated from one that may exist after the gap
|
||||
if (isOntimeDelay(processedData.previousEntry)) {
|
||||
correctedDelay = processedData.previousEntry.duration;
|
||||
processedData.totalDelay -= correctedDelay;
|
||||
if (isOntimeDelay(rundownMetadata.previousEntry)) {
|
||||
correctedDelay = rundownMetadata.previousEntry.duration;
|
||||
rundownMetadata.totalDelay -= correctedDelay;
|
||||
}
|
||||
processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0);
|
||||
processedData.totalDelay += correctedDelay;
|
||||
rundownMetadata.totalDelay = Math.max(rundownMetadata.totalDelay - entry.gap, 0);
|
||||
rundownMetadata.totalDelay += correctedDelay;
|
||||
}
|
||||
|
||||
// current event delay is the current accumulated delay
|
||||
currentEntry.delay = processedData.totalDelay;
|
||||
entry.delay = rundownMetadata.totalDelay;
|
||||
|
||||
// assign data for next iteration
|
||||
processedData.previousEvent = currentEntry;
|
||||
rundownMetadata.previousEvent = entry;
|
||||
|
||||
// lastEntry is the event with the latest end time
|
||||
if (isNewLatest(currentEntry, processedData.latestEvent)) {
|
||||
processedData.latestEvent = currentEntry;
|
||||
processedData.lastEnd = currentEntry.timeEnd;
|
||||
if (isNewLatest(entry, rundownMetadata.latestEvent)) {
|
||||
rundownMetadata.latestEvent = entry;
|
||||
rundownMetadata.lastEnd = entry.timeEnd;
|
||||
}
|
||||
}
|
||||
} else if (isOntimeDelay(currentEntry)) {
|
||||
} else if (isOntimeDelay(entry)) {
|
||||
// !!! this must happen after handling the links
|
||||
processedData.totalDelay += currentEntry.duration;
|
||||
currentEntry.parent = childOfGroup;
|
||||
rundownMetadata.totalDelay += entry.duration;
|
||||
entry.parent = childOfGroup;
|
||||
}
|
||||
|
||||
if (!childOfGroup) {
|
||||
processedData.order.push(currentEntry.id);
|
||||
rundownMetadata.order.push(entry.id);
|
||||
}
|
||||
processedData.entries[currentEntry.id] = currentEntry;
|
||||
processedData.previousEntry = currentEntry;
|
||||
rundownMetadata.entries[entry.id] = entry;
|
||||
rundownMetadata.previousEntry = entry;
|
||||
|
||||
return { processedData, processedEntry: currentEntry };
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -5,26 +5,28 @@ import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import { paramsWithId } from '../validation-utils/validationFunction.js';
|
||||
import { getCurrentRundown } from './rundown.dao.js';
|
||||
import { getCurrentRundown, getProcessedRundown } from './rundown.dao.js';
|
||||
import {
|
||||
addEntry,
|
||||
applyDelay,
|
||||
batchEditEntries,
|
||||
cloneEntry,
|
||||
createNewRundown,
|
||||
deleteAllEntries,
|
||||
deleteEntries,
|
||||
deleteRundown,
|
||||
duplicateExistingRundown,
|
||||
editEntry,
|
||||
groupEntries,
|
||||
initRundown,
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
renumberEntries,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
} from './rundown.service.js';
|
||||
import { duplicateRundown, normalisedToRundownArray } from './rundown.utils.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
clonePostValidator,
|
||||
entryBatchPutValidator,
|
||||
@@ -34,8 +36,8 @@ import {
|
||||
entryReorderValidator,
|
||||
entrySwapValidator,
|
||||
rundownArrayOfIds,
|
||||
rundownPatchValidator,
|
||||
rundownPostValidator,
|
||||
validateRundownMutation,
|
||||
} from './rundown.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -45,7 +47,7 @@ export const router: Router = express.Router();
|
||||
/**
|
||||
* Returns all rundowns in the project
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
router.get('/', (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
});
|
||||
@@ -53,11 +55,24 @@ router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
/**
|
||||
* Returns the current rundown
|
||||
*/
|
||||
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||
router.get('/current', (_req: Request, res: Response<Rundown>) => {
|
||||
const rundown = getCurrentRundown();
|
||||
res.json(rundown);
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a given rundown in its normalised client shape
|
||||
*/
|
||||
router.get('/:id', paramsWithId, (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = getProcessedRundown(req.params.id);
|
||||
res.json(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(404).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads a given rundown
|
||||
*/
|
||||
@@ -76,11 +91,7 @@ router.post('/:id/load', paramsWithId, async (req: Request, res: Response<Projec
|
||||
*/
|
||||
router.post('/', rundownPostValidator, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const emptyRundown = makeNewRundown();
|
||||
emptyRundown.title = req.body.title;
|
||||
await getDataProvider().setRundown(emptyRundown.id, emptyRundown);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const projectRundowns = await createNewRundown(req.body.title);
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -96,13 +107,7 @@ router.post(
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
|
||||
const duplicatedRundown: Rundown = duplicateRundown(rundown, `Copy of ${rundown.title}`);
|
||||
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const projectRundowns = await duplicateExistingRundown(req.params.id);
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -115,54 +120,26 @@ router.post(
|
||||
* Patches the data of an existing rundown
|
||||
* Currently only the title can be changed
|
||||
*/
|
||||
router.patch('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
if (!rundown) throw new Error(`Rundown with ID ${req.params.id} not found`);
|
||||
if (!req.body.title) throw new Error('No title provided');
|
||||
|
||||
await dataProvider.setRundown(rundown.id, { ...rundown, title: req.body.title });
|
||||
|
||||
/**
|
||||
* If loaded we re-init the rundown
|
||||
* This is likely over-kill but the simplest way to ensure state consistency
|
||||
*/
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
await initRundown(rundown, customField);
|
||||
router.patch(
|
||||
'/:id',
|
||||
rundownPatchValidator,
|
||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const projectRundowns = await renameRundown(req.params.id, req.body.title);
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a rundown if not loaded
|
||||
*/
|
||||
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
res.status(400).send({ message: 'Cannot delete loaded rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
const projectRundowns = dataProvider.getProjectRundowns();
|
||||
|
||||
if (Object.keys(projectRundowns).length <= 1) {
|
||||
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
|
||||
res.status(400).send({ message: 'Cannot delete the last rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
await dataProvider.deleteRundown(req.params.id);
|
||||
const newProjectRundowns = getDataProvider().getProjectRundowns();
|
||||
const newProjectRundowns = await deleteRundown(req.params.id);
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -180,10 +157,9 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
|
||||
router.post(
|
||||
'/:rundownId/entry',
|
||||
entryPostValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
try {
|
||||
const newEvent = await addEntry(req.body);
|
||||
const newEvent = await addEntry(req.params.rundownId, req.body);
|
||||
res.status(201).send(newEvent);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -195,20 +171,15 @@ router.post(
|
||||
/**
|
||||
* Edits an entry in a given rundown
|
||||
*/
|
||||
router.put(
|
||||
'/:rundownId/entry',
|
||||
entryPutValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
try {
|
||||
const event = await editEntry(req.body);
|
||||
res.status(200).send(event);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.put('/:rundownId/entry', entryPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
try {
|
||||
const event = await editEntry(req.params.rundownId, req.body);
|
||||
res.status(200).send(event);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Edits an entry in a given rundown
|
||||
@@ -216,10 +187,9 @@ router.put(
|
||||
router.put(
|
||||
'/:rundownId/batch',
|
||||
entryBatchPutValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
||||
const rundown = await batchEditEntries(req.params.rundownId, req.body.ids, req.body.data);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -234,11 +204,10 @@ router.put(
|
||||
router.patch(
|
||||
'/:rundownId/reorder',
|
||||
entryReorderValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const { entryId, destinationId, order } = req.body;
|
||||
const rundown = await reorderEntry(entryId, destinationId, order);
|
||||
const rundown = await reorderEntry(req.params.rundownId, entryId, destinationId, order);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -253,10 +222,9 @@ router.patch(
|
||||
router.patch(
|
||||
'/:rundownId/applydelay/:id',
|
||||
paramsWithId,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await applyDelay(req.params.id);
|
||||
const rundown = await applyDelay(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -268,20 +236,15 @@ router.patch(
|
||||
/**
|
||||
* Swaps data between two Ontime events
|
||||
*/
|
||||
router.patch(
|
||||
'/:rundownId/swap',
|
||||
entrySwapValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await swapEvents(req.body.from, req.body.to);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.patch('/:rundownId/swap', entrySwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await swapEvents(req.params.rundownId, req.body.from, req.body.to);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Clones the contents of an entry into a new one
|
||||
@@ -290,10 +253,9 @@ router.post(
|
||||
'/:rundownId/clone/:id',
|
||||
paramsWithId,
|
||||
clonePostValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await cloneEntry(req.params.id, {
|
||||
const rundown = await cloneEntry(req.params.rundownId, req.params.id, {
|
||||
before: req.body?.before,
|
||||
after: req.body?.after,
|
||||
});
|
||||
@@ -308,38 +270,28 @@ router.post(
|
||||
/**
|
||||
* Creates a group out of a list of entries
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/group',
|
||||
rundownArrayOfIds,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await groupEntries(req.body.ids);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.post('/:rundownId/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await groupEntries(req.params.rundownId, req.body.ids);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Dissolves a group by moving its children to the main rundown
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/ungroup/:id',
|
||||
paramsWithId,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await ungroupEntries(req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await ungroupEntries(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
@@ -347,10 +299,9 @@ router.post(
|
||||
router.delete(
|
||||
'/:rundownId/entries',
|
||||
rundownArrayOfIds,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteEntries(req.body.ids);
|
||||
const rundown = await deleteEntries(req.params.rundownId, req.body.ids);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -362,19 +313,15 @@ router.delete(
|
||||
/**
|
||||
* Deletes all entries in a given rundown
|
||||
*/
|
||||
router.delete(
|
||||
'/:rundownId/all',
|
||||
validateRundownMutation,
|
||||
async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteAllEntries();
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
router.delete('/:rundownId/all', async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteAllEntries(req.params.rundownId);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorders two entries in a rundown
|
||||
@@ -382,11 +329,10 @@ router.delete(
|
||||
router.patch(
|
||||
'/:rundownId/renumber',
|
||||
entryRenumberValidator,
|
||||
validateRundownMutation,
|
||||
(req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const { ids, prefix, start, increment } = matchedData<RenumberCues>(req);
|
||||
const rundown = renumberEntries(ids, prefix, start, increment);
|
||||
const rundown = await renumberEntries(req.params.rundownId, ids, prefix, start, increment);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -16,18 +16,19 @@ import {
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||
import { customFieldLabelToKey, generateId, getInsertAfterId, resolveInsertParent } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import {
|
||||
createTransaction,
|
||||
customFieldMutation,
|
||||
getCurrentRundown,
|
||||
getCurrentRundownId,
|
||||
rundownCache,
|
||||
rundownMutation,
|
||||
updateBackgroundRundown,
|
||||
@@ -38,8 +39,8 @@ import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.util
|
||||
/**
|
||||
* creates a new entry with given data
|
||||
*/
|
||||
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
// we allow the user to provide an ID, but make sure it is unique
|
||||
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||
@@ -66,7 +67,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
// make mutations to rundown
|
||||
rundownMutation.add(rundown, newEntry, afterId, parent);
|
||||
|
||||
const { rundownMetadata, revision } = commit();
|
||||
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -74,17 +75,17 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
});
|
||||
|
||||
return newEntry;
|
||||
return responseRundown.entries[newEntry.id] ?? newEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a patch to an entry in the rundown
|
||||
*/
|
||||
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function editEntry(rundownId: string, patch: PatchWithId): Promise<OntimeEntry> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
const currentEntry = rundown.entries[patch.id];
|
||||
|
||||
/**
|
||||
@@ -110,7 +111,7 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
}
|
||||
|
||||
const { entry, didInvalidate } = rundownMutation.edit(rundown, patch);
|
||||
const { rundownMetadata, revision } = commit(didInvalidate);
|
||||
const { rundown: responseRundown, rundownMetadata, revision } = await commit(didInvalidate);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -118,17 +119,24 @@ export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: didInvalidate ? true : [entry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, {
|
||||
timer: didInvalidate ? true : [entry.id],
|
||||
external: true,
|
||||
});
|
||||
});
|
||||
|
||||
return entry;
|
||||
return responseRundown.entries[entry.id] ?? entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a patch to several entries in the rundown
|
||||
*/
|
||||
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function batchEditEntries(
|
||||
rundownId: string,
|
||||
ids: EntryId[],
|
||||
patch: Partial<OntimeEntry>,
|
||||
): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
/**
|
||||
* We can do some validation globally, but mostly we will validate each entry individually
|
||||
@@ -173,7 +181,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
batchDidInvalidate = true;
|
||||
}
|
||||
}
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit(batchDidInvalidate);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -181,7 +189,10 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: batchDidInvalidate ? true : changedIds, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, {
|
||||
timer: batchDidInvalidate ? true : changedIds,
|
||||
external: true,
|
||||
});
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -190,8 +201,8 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
||||
/**
|
||||
* Deletes a known entry from the current rundown
|
||||
*/
|
||||
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function deleteEntries(rundownId: string, entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
const entry = rundown.entries[entryIds[i]];
|
||||
@@ -201,7 +212,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
rundownMutation.remove(rundown, entry);
|
||||
}
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -209,7 +220,7 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: entryIds, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -218,12 +229,12 @@ export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
/**
|
||||
* Deletes all entries from the current rundown
|
||||
*/
|
||||
export async function deleteAllEntries(): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function deleteAllEntries(rundownId: string): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
rundownMutation.removeAll(rundown);
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -231,7 +242,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -242,8 +253,13 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
* Handles moving across root orders (a group order and top level order)
|
||||
* @throws if entryId or destinationId not found
|
||||
*/
|
||||
export async function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function reorderEntry(
|
||||
rundownId: string,
|
||||
entryId: EntryId,
|
||||
destinationId: EntryId,
|
||||
order: 'before' | 'after' | 'insert',
|
||||
) {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
// check that both entries exist
|
||||
const eventFrom = rundown.entries[entryId];
|
||||
@@ -255,7 +271,7 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
|
||||
|
||||
rundownMutation.reorder(rundown, eventFrom, eventTo, order);
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -263,7 +279,7 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -272,22 +288,28 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
|
||||
/**
|
||||
* @throws if an id is missing or not an Ontime event
|
||||
*/
|
||||
export function renumberEntries(ids: EntryId[], prefix: string, start: string, increment: string): Rundown {
|
||||
export async function renumberEntries(
|
||||
rundownId: string,
|
||||
ids: EntryId[],
|
||||
prefix: string,
|
||||
start: string,
|
||||
increment: string,
|
||||
): Promise<Rundown> {
|
||||
const startNumber = getIntegerAndFraction(start);
|
||||
const incrementNumber = getIntegerAndFraction(increment);
|
||||
|
||||
// if the prefix doesn't already include a separator or is empty, then insert a separator
|
||||
if (prefix !== '' && !prefix.endsWith('-') && !prefix.endsWith(' ')) prefix += ' ';
|
||||
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
rundownMutation.renumber(rundown, ids, prefix, startNumber, incrementNumber);
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit(false);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit(false);
|
||||
|
||||
setImmediate(() => {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
notifyChanges(rundownMetadata, revision, { timer: ids, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: ids, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -297,8 +319,8 @@ export function renumberEntries(ids: EntryId[], prefix: string, start: string, i
|
||||
* Applies a delay into the rundown effectively changing the schedule
|
||||
* The applied delay is deleted
|
||||
*/
|
||||
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function applyDelay(rundownId: string, delayId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
// check that delay exists
|
||||
const delay = rundown.entries[delayId];
|
||||
@@ -310,7 +332,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
rundownMutation.applyDelay(rundown, delay);
|
||||
rundownMutation.remove(rundown, delay);
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -318,7 +340,7 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -327,8 +349,8 @@ export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||
/**
|
||||
* Swaps the data between two events in the rundown
|
||||
*/
|
||||
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function swapEvents(rundownId: string, fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
const eventFrom = rundown.entries[fromId];
|
||||
const eventTo = rundown.entries[toId];
|
||||
|
||||
@@ -343,7 +365,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
}
|
||||
|
||||
rundownMutation.swap(rundown, eventFrom, eventTo);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -351,7 +373,7 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -362,8 +384,8 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
* Handles cloning children if the entry is a group
|
||||
* @throws if the entry to clone does not exist
|
||||
*/
|
||||
export async function cloneEntry(entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
const originalEntry = rundown.entries[entryId];
|
||||
|
||||
if (!originalEntry) {
|
||||
@@ -371,7 +393,7 @@ export async function cloneEntry(entryId: EntryId, options: InsertOptions): Prom
|
||||
}
|
||||
|
||||
const newEntry = rundownMutation.clone(rundown, originalEntry, options);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -380,11 +402,11 @@ export async function cloneEntry(entryId: EntryId, options: InsertOptions): Prom
|
||||
|
||||
// notify timer and external services of change
|
||||
if (isOntimeGroup(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { timer: newEntry.entries, external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: newEntry.entries, external: true });
|
||||
} else if (isOntimeEvent(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||
} else if (isOntimeDelay(newEntry)) {
|
||||
notifyChanges(rundownMetadata, revision, { external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -394,11 +416,11 @@ export async function cloneEntry(entryId: EntryId, options: InsertOptions): Prom
|
||||
/**
|
||||
* Groups a list of entries into a new group
|
||||
*/
|
||||
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function groupEntries(rundownId: string, entryIds: EntryId[]): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
rundownMutation.group(rundown, entryIds);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -406,7 +428,7 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be grouping a running event
|
||||
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -415,8 +437,8 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
/**
|
||||
* Deletes a group and moves all its children to the top level
|
||||
*/
|
||||
export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
export async function ungroupEntries(rundownId: string, groupId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
const group = rundown.entries[groupId];
|
||||
if (!group || !isOntimeGroup(group)) {
|
||||
@@ -424,7 +446,7 @@ export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
|
||||
}
|
||||
|
||||
rundownMutation.ungroup(rundown, group);
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
@@ -432,7 +454,7 @@ export async function ungroupEntries(groupId: EntryId): Promise<Rundown> {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we dont need to notify the timer since the grouping does not affect the runtime
|
||||
notifyChanges(rundownMetadata, revision, { external: true });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -459,7 +481,7 @@ export async function createCustomField(customField: CustomField): Promise<Custo
|
||||
customFieldMutation.add(customFields, key, customField);
|
||||
|
||||
// Adding a custom field has no immediate implications on the rundown
|
||||
const { customFields: resultCustomFields } = commit(false);
|
||||
const { customFields: resultCustomFields } = await commit(false);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
@@ -517,12 +539,12 @@ export async function editCustomField(
|
||||
}
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = await commit(false);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
@@ -556,12 +578,12 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
||||
customFieldMutation.remove(customFields, key);
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = await commit(false);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
notifyChanges(undefined, rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
return resultCustomFields;
|
||||
@@ -573,7 +595,8 @@ export async function deleteCustomField(key: CustomFieldKey, projectRundowns: Pr
|
||||
*
|
||||
* @private - exported for testing
|
||||
*/
|
||||
export function updateRuntimeOnChange(rundownMetadata: RundownMetadata) {
|
||||
export function updateRuntimeOnChange(rundownMetadata: RundownMetadata | null) {
|
||||
if (!rundownMetadata) return;
|
||||
// we only declare the amount of playable events
|
||||
const numEvents = rundownMetadata.timedEventOrder.length;
|
||||
|
||||
@@ -592,29 +615,40 @@ type NotifyChangesOptions = {
|
||||
|
||||
/**
|
||||
* Notify services of changes in the rundown
|
||||
* TODO: we could receive a runtime flag to call updateRuntimeOnChange
|
||||
* instead of having it in every consumer
|
||||
*/
|
||||
function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
|
||||
// notify timer service of changed events
|
||||
if (options.timer) {
|
||||
function notifyChanges(
|
||||
rundownId: string | undefined,
|
||||
rundownMetadata: RundownMetadata | null,
|
||||
revision: number,
|
||||
options: NotifyChangesOptions,
|
||||
) {
|
||||
// notify timer service of changed event
|
||||
if (rundownMetadata && options.timer && rundownId && isCurrentRundown(rundownId)) {
|
||||
runtimeService.notifyOfChangedEvents(rundownMetadata);
|
||||
}
|
||||
|
||||
// notify external services of changes
|
||||
if (options.reload) {
|
||||
sendRefetch(RefetchKey.All);
|
||||
} else if (options.external) {
|
||||
sendRefetch(RefetchKey.Rundown, revision);
|
||||
sendRefetch(RefetchKey.Rundown, revision, rundownId);
|
||||
}
|
||||
}
|
||||
|
||||
export function isCurrentRundown(id: string) {
|
||||
return id === getCurrentRundownId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function loadRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
if (id === getCurrentRundown().id) {
|
||||
if (isCurrentRundown(id)) {
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
await initRundown(rundown, customField);
|
||||
@@ -637,9 +671,94 @@ export async function initRundown(
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
setImmediate(() => {
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload });
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
setLastLoadedRundown(rundown.id).catch((error) => {
|
||||
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNewRundown(title: string) {
|
||||
const emptyRundown = makeNewRundown();
|
||||
emptyRundown.title = title;
|
||||
await getDataProvider().setRundown(emptyRundown.id, emptyRundown);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
return projectRundowns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames an existing rundown
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function renameRundown(id: string, title: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
await dataProvider.setRundown(id, { ...rundown, title });
|
||||
|
||||
/**
|
||||
* If loaded we re-init the rundown
|
||||
* This is likely over-kill but the simplest way to ensure state consistency
|
||||
*/
|
||||
if (isCurrentRundown(id)) {
|
||||
await initRundown(dataProvider.getRundown(id), dataProvider.getCustomFields());
|
||||
} else {
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
}
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing rundown without making it the loaded one
|
||||
* @throws if the provided id does not exist
|
||||
*/
|
||||
export async function duplicateExistingRundown(id: string) {
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(id);
|
||||
|
||||
const duplicatedRundown: Rundown = structuredClone(rundown);
|
||||
duplicatedRundown.id = generateId();
|
||||
duplicatedRundown.title = `Copy of ${rundown.title}`;
|
||||
duplicatedRundown.revision = 0;
|
||||
|
||||
await dataProvider.setRundown(duplicatedRundown.id, duplicatedRundown);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a rundown
|
||||
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
||||
*/
|
||||
export async function deleteRundown(id: string) {
|
||||
if (isCurrentRundown(id)) {
|
||||
throw new Error('Cannot delete loaded rundown');
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
if (Object.keys(dataProvider.getProjectRundowns()).length <= 1) {
|
||||
throw new Error('Cannot delete the last rundown');
|
||||
}
|
||||
|
||||
const projectRundowns = await dataProvider.deleteRundown(id);
|
||||
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
|
||||
return projectRundowns;
|
||||
}
|
||||
|
||||
@@ -34,43 +34,34 @@ import {
|
||||
|
||||
import { RundownMetadata } from './rundown.types.js';
|
||||
|
||||
type CompleteEntry<T> =
|
||||
T extends Partial<OntimeEvent>
|
||||
? OntimeEvent
|
||||
: T extends Partial<OntimeDelay>
|
||||
? OntimeDelay
|
||||
: T extends Partial<OntimeGroup>
|
||||
? OntimeGroup
|
||||
: T extends Partial<OntimeMilestone>
|
||||
? OntimeMilestone
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Generates a fully formed RundownEntry of the patch type
|
||||
*/
|
||||
export function generateEvent<
|
||||
T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
|
||||
>(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry<T> {
|
||||
export function generateEvent(
|
||||
rundown: Rundown,
|
||||
eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
|
||||
afterId: EntryId | null,
|
||||
parent?: EntryId,
|
||||
): OntimeEntry {
|
||||
if (isOntimeEvent(eventData)) {
|
||||
return createEvent(
|
||||
eventData,
|
||||
getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent),
|
||||
) as CompleteEntry<T>;
|
||||
const event = createEvent(eventData, getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent));
|
||||
if (!event) throw new Error('Invalid event type');
|
||||
return event;
|
||||
}
|
||||
|
||||
const id = eventData.id || getUniqueId(rundown);
|
||||
|
||||
if (isOntimeDelay(eventData)) {
|
||||
return createDelay({ duration: eventData.duration ?? 0, id }) as CompleteEntry<T>;
|
||||
return createDelay({ duration: eventData.duration ?? 0, id });
|
||||
}
|
||||
|
||||
// TODO(v4): allow user to provide a larger patch of the group entry
|
||||
if (isOntimeGroup(eventData)) {
|
||||
return createGroup({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
|
||||
return createGroup({ id, title: eventData.title ?? '' });
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(eventData)) {
|
||||
return createMilestone({ ...eventData, id }) as CompleteEntry<T>;
|
||||
return createMilestone({ ...eventData, id });
|
||||
}
|
||||
|
||||
throw new Error('Invalid event type');
|
||||
@@ -460,20 +451,6 @@ export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRund
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicates an existing rundown ensuring all IDs are unique
|
||||
*/
|
||||
export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
|
||||
const newRundownId = generateId();
|
||||
|
||||
const newRundown = structuredClone(rundown);
|
||||
newRundown.id = newRundownId;
|
||||
newRundown.title = newTitle;
|
||||
newRundown.revision = 0;
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
export type IncrementNumber = {
|
||||
integer: number;
|
||||
faction: number;
|
||||
|
||||
@@ -1,39 +1,19 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { getCurrentRundown } from './rundown.dao.js';
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
|
||||
export const rundownPatchValidator = [
|
||||
param('id').isString().trim().notEmpty(),
|
||||
body('title').isString().trim().notEmpty().withMessage('No title provided'),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
/**
|
||||
* Middleware prevents mutating a rundown that is not selected
|
||||
* This allows our service to still only handle the current rundown
|
||||
*
|
||||
* This would need to be removed in favour or rundown selection if we would like
|
||||
* to implement the mutation of background rundowns
|
||||
*/
|
||||
export async function validateRundownMutation(req: Request, res: Response, next: NextFunction) {
|
||||
const { rundownId } = req.params;
|
||||
|
||||
try {
|
||||
if (getCurrentRundown().id !== rundownId) {
|
||||
res.status(404).json({ message: 'Cannot mutate not selected rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(404).json({ message: 'Rundown not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const entryPostValidator = [
|
||||
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
||||
body('after').optional().isString(),
|
||||
|
||||
@@ -63,7 +63,7 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
|
||||
throw new Error('Missing Event ID');
|
||||
}
|
||||
|
||||
const { entries } = getCurrentRundown();
|
||||
const { entries, id: currentRundownId } = getCurrentRundown();
|
||||
const customFields = getProjectCustomFields();
|
||||
|
||||
const targetEntry = entries[id];
|
||||
@@ -89,8 +89,8 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
|
||||
Object.assign(patchEntry, newObjectProperty);
|
||||
}
|
||||
});
|
||||
//TODO: windowed edit function
|
||||
await editEntry(patchEntry);
|
||||
|
||||
await editEntry(currentRundownId, patchEntry);
|
||||
return { payload: 'success' };
|
||||
},
|
||||
/* Message Service */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Ontime MCP Server
|
||||
|
||||
Ontime exposes an MCP server over Streamable HTTP.
|
||||
|
||||
## Endpoint
|
||||
|
||||
Default local URL:
|
||||
|
||||
```text
|
||||
http://localhost:4001/mcp
|
||||
```
|
||||
|
||||
If Ontime is running on another host or port, replace `localhost:4001` with that address.
|
||||
If `ROUTER_PREFIX` is configured, include it before `/mcp`, for example:
|
||||
|
||||
```text
|
||||
http://localhost:4001/stage/mcp
|
||||
```
|
||||
|
||||
The MCP route is stateless. Clients should send MCP requests with `POST`; `GET` and
|
||||
`DELETE` are not used.
|
||||
|
||||
## Authentication
|
||||
|
||||
If Ontime has no session password configured, no MCP authentication is required.
|
||||
|
||||
If a session password is configured, authenticate with the hashed Ontime token.
|
||||
The settings UI generates an endpoint URL with the token in the query string:
|
||||
|
||||
```text
|
||||
http://localhost:4001/mcp?token=<hashed-token>
|
||||
```
|
||||
|
||||
If your MCP client supports request headers, you can send the same token as a
|
||||
bearer token instead:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <hashed-token>
|
||||
```
|
||||
|
||||
This is the same token used in authenticated Ontime share URLs as the `token` query
|
||||
parameter. The raw session password is not accepted as the bearer token.
|
||||
|
||||
## Client Configuration
|
||||
|
||||
Use a Streamable HTTP MCP client and point it at the MCP endpoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ontime": {
|
||||
"url": "http://localhost:4001/mcp?token=<hashed-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Omit the token when Ontime is not password protected. If you prefer headers, use
|
||||
`"url": "http://localhost:4001/mcp"` and add an `Authorization` header with the
|
||||
same bearer token.
|
||||
|
||||
## Quick Check
|
||||
|
||||
The server should respond to MCP initialization requests at `/mcp`. A plain browser
|
||||
`GET` request will return `405 Method not allowed`, which is expected.
|
||||
@@ -0,0 +1,255 @@
|
||||
import { SupportedEntry, type EventPostPayload, type OntimeEntry, type PatchWithId, type Rundown } from 'ontime-types';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const addEntryMock = vi.hoisted(() => vi.fn());
|
||||
const editEntryMock = vi.hoisted(() => vi.fn());
|
||||
const groupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const ungroupEntriesMock = vi.hoisted(() => vi.fn());
|
||||
const getCurrentRundownMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getCurrentRundown: getCurrentRundownMock,
|
||||
getCurrentRundownId: vi.fn(() => 'loaded-rundown'),
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
getDataProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
addEntry: addEntryMock,
|
||||
batchEditEntries: vi.fn(),
|
||||
deleteEntries: vi.fn(),
|
||||
editEntry: editEntryMock,
|
||||
groupEntries: groupEntriesMock,
|
||||
reorderEntry: vi.fn(),
|
||||
ungroupEntries: ungroupEntriesMock,
|
||||
}));
|
||||
|
||||
const { batchCreateEntriesForMcp, groupEntriesForMcp, ungroupEntryForMcp } = await import('../mcp.service.js');
|
||||
|
||||
function makeRundown(entries: Rundown['entries'], order: string[] = Object.keys(entries)): Rundown {
|
||||
return {
|
||||
id: 'loaded-rundown',
|
||||
title: 'Loaded',
|
||||
order,
|
||||
flatOrder: order,
|
||||
entries,
|
||||
revision: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mcp.service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCurrentRundownMock.mockReturnValue(makeRundown({}));
|
||||
|
||||
let id = 0;
|
||||
addEntryMock.mockImplementation(async (_rundownId: string, payload: EventPostPayload) => {
|
||||
id += 1;
|
||||
const entryId = `entry-${id}`;
|
||||
|
||||
if (payload.type === SupportedEntry.Group) {
|
||||
return {
|
||||
id: entryId,
|
||||
type: SupportedEntry.Group,
|
||||
title: payload.title ?? '',
|
||||
note: '',
|
||||
colour: '',
|
||||
custom: {},
|
||||
targetDuration: null,
|
||||
entries: [],
|
||||
revision: 0,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: entryId,
|
||||
type: payload.type ?? SupportedEntry.Event,
|
||||
title: payload.title ?? '',
|
||||
parent: 'parent' in payload ? payload.parent : null,
|
||||
} as OntimeEntry;
|
||||
});
|
||||
|
||||
editEntryMock.mockImplementation(async (_rundownId: string, patch: PatchWithId) => ({
|
||||
id: patch.id,
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Session block',
|
||||
note: patch.note ?? '',
|
||||
colour: patch.colour ?? '',
|
||||
custom: patch.custom ?? {},
|
||||
targetDuration: patch.targetDuration ?? null,
|
||||
entries: [],
|
||||
revision: 1,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates grouped batch entries with group metadata and child parent references', async () => {
|
||||
const result = await batchCreateEntriesForMcp({
|
||||
entries: [
|
||||
{
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Session block',
|
||||
note: 'Main room',
|
||||
colour: '#123456',
|
||||
targetDuration: 3_600_000,
|
||||
children: [
|
||||
{ type: SupportedEntry.Event, title: 'Talk', timeStart: 36_000_000, duration: 1_800_000 },
|
||||
{ type: SupportedEntry.Milestone, title: 'Reset stage' },
|
||||
],
|
||||
},
|
||||
{ type: SupportedEntry.Event, title: 'After block', timeStart: 39_600_000, duration: 900_000 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Group, title: 'Session block' }),
|
||||
);
|
||||
expect(editEntryMock).toHaveBeenCalledWith(
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({
|
||||
id: 'entry-1',
|
||||
note: 'Main room',
|
||||
colour: '#123456',
|
||||
targetDuration: 3_600_000,
|
||||
}),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'Talk', parent: 'entry-1' }),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({
|
||||
type: SupportedEntry.Milestone,
|
||||
title: 'Reset stage',
|
||||
parent: 'entry-1',
|
||||
after: 'entry-2',
|
||||
}),
|
||||
);
|
||||
expect(addEntryMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ type: SupportedEntry.Event, title: 'After block', after: 'entry-1' }),
|
||||
);
|
||||
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
|
||||
});
|
||||
|
||||
it('rejects nested groups before creating entries', async () => {
|
||||
await expect(
|
||||
batchCreateEntriesForMcp({
|
||||
entries: [
|
||||
{
|
||||
type: SupportedEntry.Group,
|
||||
title: 'Outer',
|
||||
children: [{ type: SupportedEntry.Group, title: 'Inner' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('Cannot create a group inside another group.');
|
||||
|
||||
expect(addEntryMock).not.toHaveBeenCalled();
|
||||
expect(editEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('groups existing top-level entries and applies group metadata', async () => {
|
||||
const sourceRundown = makeRundown(
|
||||
{
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry,
|
||||
'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: null } as OntimeEntry,
|
||||
},
|
||||
['entry-1', 'entry-2'],
|
||||
);
|
||||
const groupedRundown = makeRundown(
|
||||
{
|
||||
group: {
|
||||
id: 'group',
|
||||
type: SupportedEntry.Group,
|
||||
title: '',
|
||||
note: '',
|
||||
colour: '',
|
||||
custom: {},
|
||||
targetDuration: null,
|
||||
entries: ['entry-1', 'entry-2'],
|
||||
revision: 0,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: 0,
|
||||
isFirstLinked: false,
|
||||
},
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
'entry-2': { id: 'entry-2', type: SupportedEntry.Event, title: 'Two', parent: 'group' } as OntimeEntry,
|
||||
},
|
||||
['group'],
|
||||
);
|
||||
|
||||
getCurrentRundownMock.mockReturnValue(sourceRundown);
|
||||
groupEntriesMock.mockResolvedValue(groupedRundown);
|
||||
|
||||
const result = await groupEntriesForMcp({
|
||||
ids: ['entry-1', 'entry-2'],
|
||||
title: 'Block',
|
||||
colour: '#abcdef',
|
||||
targetDuration: 1_200_000,
|
||||
});
|
||||
|
||||
expect(groupEntriesMock).toHaveBeenCalledWith('loaded-rundown', ['entry-1', 'entry-2']);
|
||||
expect(editEntryMock).toHaveBeenCalledWith(
|
||||
'loaded-rundown',
|
||||
expect.objectContaining({ id: 'group', colour: '#abcdef', targetDuration: 1_200_000 }),
|
||||
);
|
||||
expect(result.entry.id).toBe('group');
|
||||
expect(result.order).toEqual(['group']);
|
||||
});
|
||||
|
||||
it('rejects grouping nested entries before mutating', async () => {
|
||||
getCurrentRundownMock.mockReturnValue(
|
||||
makeRundown({
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry,
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(groupEntriesForMcp({ ids: ['entry-1'] })).rejects.toThrow(
|
||||
'Cannot group nested entry entry-1. Move it out of its group first.',
|
||||
);
|
||||
|
||||
expect(groupEntriesMock).not.toHaveBeenCalled();
|
||||
expect(editEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ungroups an existing group entry', async () => {
|
||||
getCurrentRundownMock.mockReturnValue(
|
||||
makeRundown({
|
||||
group: { id: 'group', type: SupportedEntry.Group, entries: ['entry-1'] } as OntimeEntry,
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: 'group' } as OntimeEntry,
|
||||
}),
|
||||
);
|
||||
ungroupEntriesMock.mockResolvedValue(
|
||||
makeRundown(
|
||||
{
|
||||
'entry-1': { id: 'entry-1', type: SupportedEntry.Event, title: 'One', parent: null } as OntimeEntry,
|
||||
},
|
||||
['entry-1'],
|
||||
),
|
||||
);
|
||||
|
||||
const result = await ungroupEntryForMcp({ id: 'group' });
|
||||
|
||||
expect(ungroupEntriesMock).toHaveBeenCalledWith('loaded-rundown', 'group');
|
||||
expect(result).toMatchObject({ ungrouped: 'group', order: ['entry-1'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
||||
|
||||
/**
|
||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
||||
* rather than cookies or query params; any other request falls through to the
|
||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
||||
*/
|
||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
||||
if (hasPassword) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
||||
return next();
|
||||
}
|
||||
}
|
||||
return fallback(req, res, next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { GetPromptResult, ListPromptsResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export const PROMPT_DEFINITIONS: ListPromptsResult['prompts'] = [
|
||||
{
|
||||
name: 'create_rundown_from_agenda',
|
||||
description: 'Convert a plain-text agenda into an Ontime rundown using ontime_batch_create_entries',
|
||||
arguments: [{ name: 'agenda', description: 'Plain-text agenda to convert', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'bulk_edit_rundown',
|
||||
description: 'Apply a bulk change across the rundown (recolour, reschedule, skip events, etc.)',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'What to change, e.g. "colour all keynotes blue"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'validate_rundown',
|
||||
description: 'Check the current rundown for common issues: missing cues, overlaps, gaps, zero-duration events',
|
||||
arguments: [],
|
||||
},
|
||||
{
|
||||
name: 'restructure_rundown',
|
||||
description: 'Reorder events in the rundown according to an instruction',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'How to restructure, e.g. "move all breaks to after keynotes"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'manage_custom_fields',
|
||||
description: 'List, create, rename, recolour, or delete project-level custom field definitions',
|
||||
arguments: [
|
||||
{
|
||||
name: 'instruction',
|
||||
description: 'What to do, e.g. "add a Speaker field" or "delete the Camera field"',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function userPrompt(description: string, text: string): GetPromptResult {
|
||||
return { description, messages: [{ role: 'user', content: { type: 'text', text } }] };
|
||||
}
|
||||
|
||||
export function handleGetPrompt(name: string, args: Record<string, string>): GetPromptResult {
|
||||
if (name === 'create_rundown_from_agenda') {
|
||||
return userPrompt(
|
||||
'Build an Ontime rundown from a plain-text agenda',
|
||||
`Convert the following agenda into an Ontime rundown.
|
||||
Read the ontime://schema resource if you need a data model reference.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
||||
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
||||
|
||||
Entry type guidance:
|
||||
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
||||
- Use \`milestone\` for non-timed markers that don't advance playback (e.g. "Doors open", "Broadcast start").
|
||||
- Use \`delay\` only when the user explicitly wants to model schedule drift that shifts all following events.
|
||||
- Use \`group\` to collect related events into a named block. In ontime_batch_create_entries, put grouped items in the group's \`children\` array. A group may include \`title\`, \`colour\`, \`note\`, \`custom\`, and \`targetDuration\`; groups cannot be nested.
|
||||
|
||||
Event timing:
|
||||
- Provide a title plus enough timing data for Ontime to infer a timing strategy.
|
||||
- \`timeStart\` + \`duration\`: keeps duration fixed and calculates \`timeEnd\`.
|
||||
- \`timeStart\` + \`timeEnd\`: keeps end time fixed and calculates \`duration\`.
|
||||
- \`timeEnd\` + \`duration\`: calculates \`timeStart\`.
|
||||
- Avoid sending \`timeStart\`, \`timeEnd\`, and \`duration\` together unless you intentionally want Ontime to prioritise duration and recalculate \`timeEnd\`.
|
||||
|
||||
Timer type (timerType):
|
||||
- \`count-down\` (default): counts down from duration. Use for most timed sessions.
|
||||
- \`count-up\`: counts elapsed time. Use for open-ended items like Q&A or audience discussion.
|
||||
- \`clock\`: shows wall-clock time. Use for broadcast-start or house-open markers.
|
||||
- \`none\`: no timer shown. Use for purely informational or non-timed items.
|
||||
|
||||
Count to end (countToEnd):
|
||||
- This is an advanced countdown behaviour, not a timer type.
|
||||
- When \`countToEnd: true\`, the timer counts to the scheduled \`timeEnd\` instead of counting down the event duration.
|
||||
- This can surprise operators if an event starts late or the schedule shifts, because the displayed time may be shorter or longer than the nominal duration.
|
||||
- Do not set \`countToEnd: true\` unless the user explicitly asks for "count to end", "count to scheduled end", or confirms after you explain this behaviour.
|
||||
|
||||
End action (endAction):
|
||||
- Leave \`endAction\` unset unless the user explicitly asks for automatic cueing or chaining. It defaults to \`none\`.
|
||||
- \`load-next\` and \`play-next\` create playback automations that remove operator control between events and can surprise operators. Do NOT add them just because events are scheduled back-to-back — a seamless schedule does not imply automated playback.
|
||||
- \`none\` (default): stops at end; operator manually starts the next event. Use this for essentially every event unless told otherwise.
|
||||
- \`load-next\`: pre-arms the next event; operator triggers start. Only set when the user explicitly asks for it.
|
||||
- \`play-next\`: automatically starts the next event. Only set when the user explicitly asks for an automatic playback chain; confirm they understand it removes operator control between those events.
|
||||
|
||||
Linking (linkStart):
|
||||
- \`linkStart\` controls schedule-change propagation through the rundown.
|
||||
- When an event is linked, it inherits the end time of the previous playable event as its start time.
|
||||
- The event's \`timeStrategy\` decides how it adapts to the inherited start: lock duration updates the end time; lock end updates the duration.
|
||||
- Ideal for segments within a block where only the anchor start time and individual durations are managed directly.
|
||||
|
||||
Flags (flag):
|
||||
- Set \`flag: true\` on events that are critical operational markers (keynote starts, broadcast moments, VIP arrivals).
|
||||
- The operator view shows a countdown to the next flagged event — use sparingly for maximum impact.
|
||||
|
||||
Colours:
|
||||
- Ask the user what colour convention they use before applying any colours.
|
||||
- Common pattern: one colour per event type (keynotes, panels, breaks, meals).
|
||||
- Colours are hex strings: \`#RRGGBB\`.
|
||||
|
||||
Custom fields (custom):
|
||||
- Call ontime_get_custom_fields for the project's field keys (cuesheet-style columns such as camera, graphics, speaker).
|
||||
- Store values per entry at \`custom: { <fieldKey>: <value> }\` — only use keys that exist in the project.
|
||||
- If the user's requested field is ambiguous, show the existing field list before choosing a key, so you avoid duplicate concepts such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
|
||||
Agenda:
|
||||
${args.agenda}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'bulk_edit_rundown') {
|
||||
return userPrompt(
|
||||
'Apply a bulk change across the rundown',
|
||||
`Apply the following bulk edit to the current Ontime rundown: "${args.instruction}"
|
||||
|
||||
Strategy:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current events, their IDs, and field values.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Determine which event IDs are affected by the instruction.
|
||||
5. If the user asks to group existing top-level entries: call ontime_group_entries once with { ids, title/note/colour/custom/targetDuration as needed, rundownId }.
|
||||
6. If the user asks to dissolve a group: call ontime_ungroup_entry with the group id and rundownId.
|
||||
7. If every affected entry receives the SAME field values (e.g. "colour all keynotes purple", "skip all breaks"): call ontime_batch_update_entries once with { ids, data, rundownId }.
|
||||
8. If each event needs DIFFERENT field values (e.g. "shift everything 30 minutes later"): check first if events use linkStart. If they do, changing the anchor event's timeStart or duration can cascade to linked followers — you may only need to update one event. Otherwise, compute the new values per event and call ontime_update_entry for each.
|
||||
|
||||
Time shift mechanics:
|
||||
- All time fields are milliseconds from midnight; compute arithmetic before calling the tools.
|
||||
- timeEnd - timeStart = duration. When shifting times, decide whether to keep duration fixed (timeEnd moves with timeStart) or keep timeEnd fixed (duration shrinks). Provide only the fields you intend to change — the server infers the strategy from which fields are present.
|
||||
- For "shift everything N minutes later": update timeStart and timeEnd per event (or just timeStart on the anchor event of a linkStart chain). Do not use ontime_batch_update_entries for this unless every target event should receive the exact same timeStart/timeEnd values.
|
||||
|
||||
Automation risks:
|
||||
- Setting \`endAction: 'play-next'\` on multiple events creates an automatic playback chain that removes operator control between those events. Confirm with the user before applying.
|
||||
- Bulk-setting \`skip: true\` will hide events from playback. Confirm before applying to many events.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'validate_rundown') {
|
||||
return userPrompt(
|
||||
'Check the current rundown for common issues',
|
||||
`Validate the currently loaded Ontime rundown and report issues.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_get_rundown to read all events and their fields.
|
||||
2. Call ontime_get_rundown_metadata for totals (totalDuration, totalDelay, totalDays, firstStart, lastEnd, flags).
|
||||
|
||||
Check and report:
|
||||
|
||||
Schedule integrity:
|
||||
- Events with missing \`cue\` or \`title\`: these are usually worth checking, but not necessarily errors
|
||||
- Events with \`duration\` of 0 or negative
|
||||
- Events with \`gap < 0\`: overlaps the previous timed event and is a conflict
|
||||
- Large unexplained positive gaps between consecutive timed events (> 30 min): check whether these are intentional
|
||||
- Events where \`timeEnd < timeStart\`: these cross midnight; confirm this is intentional
|
||||
- Events whose \`timeStart\` is the same as or earlier than the previous playable event's \`timeStart\`: Ontime schedules these on the next day; confirm this is intentional
|
||||
|
||||
Timing and linking:
|
||||
- \`metadata.totalDays > 0\`: show spans midnight — confirm this is intentional
|
||||
- \`metadata.totalDelay !== 0\`: active delay entries are shifting the schedule by this many ms; report the net shift
|
||||
- Events with \`linkStart: true\` that are first in the rundown (no predecessor to link to)
|
||||
- Events with \`countToEnd: true\`: confirm the operator expects countdowns to target scheduled end time instead of event duration, especially if starts can drift.
|
||||
|
||||
Automation:
|
||||
- Events with \`endAction: 'play-next'\`: these form automatic playback chains. List each chain so the user can confirm the automation is intentional.
|
||||
- Events with \`flag: true\`: these are the operator's critical markers. List them so the user can verify they are correct and complete.
|
||||
|
||||
Skipped events:
|
||||
- Events with \`skip: true\` — confirm with the user these are intentional
|
||||
|
||||
Totals:
|
||||
- Total rundown duration and whether it matches the user's expected show length (ask if unknown)
|
||||
|
||||
Present issues grouped by severity: ERROR (breaks playback), WARNING (likely mistake), INFO (worth confirming).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'restructure_rundown') {
|
||||
return userPrompt(
|
||||
'Reorder events in the rundown',
|
||||
`Restructure the current Ontime rundown: "${args.instruction}"
|
||||
|
||||
Steps:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see the current order, groups, and event fields.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP reorders the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Note which events are inside groups (check each group's \`entries\` array vs the top-level \`order\` array).
|
||||
5. If the instruction groups existing top-level entries into a new group, call ontime_group_entries with the selected IDs and any group metadata.
|
||||
6. If the instruction dissolves a group, call ontime_ungroup_entry with that group ID.
|
||||
7. Otherwise compute the target arrangement as a sequence of moves.
|
||||
8. For each event that needs to move, call ontime_reorder_entry:
|
||||
- \`order: 'before'\` or \`'after'\` — places the event as a sibling next to destinationId
|
||||
- \`order: 'insert'\` — places the event inside a group (destinationId must be the group's ID)
|
||||
9. Call ontime_get_rundown again to confirm the new order.
|
||||
|
||||
Group awareness:
|
||||
- Events inside a group appear in the group's \`entries\` array, not in the top-level \`order\`.
|
||||
- To create a group from existing top-level entries, prefer ontime_group_entries over creating an empty group and moving items one by one.
|
||||
- To dissolve a group, use ontime_ungroup_entry.
|
||||
- To move an event out of a group, reorder it before/after a top-level entry.
|
||||
- To move an event into a group, use \`order: 'insert'\` with the group as destinationId.
|
||||
- A group's \`targetDuration\` is a planning hint only — moving events in or out does not break anything.
|
||||
|
||||
Efficiency tip: plan moves in the direction of the target position to minimise reorder calls. Avoid moving the same event twice — compute the full target sequence before issuing any calls.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'manage_custom_fields') {
|
||||
return userPrompt(
|
||||
'List, create, rename, recolour, or delete custom field definitions',
|
||||
`Manage the project-level custom field definitions for this Ontime project: "${args.instruction}"
|
||||
|
||||
Custom fields are project-scoped. They appear as columns in the cuesheet and can be set on any event, milestone, or group. Changes apply to all rundowns in the project.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_get_custom_fields to see what fields already exist. This is mandatory before creating — to avoid duplicates such as "Cam", "camera", and "Cameras".
|
||||
2. Carry out the instruction using the tools below. Confirm destructive changes (rename, delete) with the user before calling.
|
||||
|
||||
Creating a field:
|
||||
- Call ontime_create_custom_field with { label, type, colour }.
|
||||
- label: human-readable name (alphanumeric with spaces). The key is auto-derived: spaces → underscores (e.g. "Camera Angle" → "Camera_Angle"). Confirm the derived key with the user before creating.
|
||||
- type: "text" for short string values, "image" for image URLs. Cannot be changed after creation.
|
||||
- colour: hex colour (#RRGGBB) used to visually identify this column in the cuesheet. Ask the user what colour to use if not specified.
|
||||
- Returns: { key, customFields } — use the returned key when setting values on entries.
|
||||
|
||||
Renaming or recolouring a field:
|
||||
- Call ontime_update_custom_field with { key, label?, colour? }.
|
||||
- Warning: changing the label changes the derived key and cascades to all entry references in all rundowns. Confirm with the user before renaming.
|
||||
- The field type cannot be changed.
|
||||
|
||||
Deleting a field:
|
||||
- Call ontime_delete_custom_field with { key }.
|
||||
- Warning: this permanently removes the field definition and its values from every entry in all rundowns. Confirm with the user before deleting.
|
||||
|
||||
After any mutation, call ontime_get_custom_fields again to confirm the result and show the user the updated field list with their keys.`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown prompt: ${name}`);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ListResourcesResult, ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getCurrentRundown, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { ONTIME_DOCS_MARKDOWN, ONTIME_SCHEMA_MARKDOWN } from './mcp.schema.js';
|
||||
|
||||
export const RESOURCE_DEFINITIONS: ListResourcesResult['resources'] = [
|
||||
{
|
||||
uri: 'ontime://schema',
|
||||
name: 'ontime-schema',
|
||||
title: 'Ontime data model reference',
|
||||
description:
|
||||
'Markdown reference for rundown structure, event fields, time format, and entry types. Read once per session to ground tool calls in the correct data model.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://rundown/current',
|
||||
name: 'current-rundown',
|
||||
title: 'Currently loaded rundown',
|
||||
description:
|
||||
'The rundown currently active in Ontime, with its full entries map and order. Re-read after any mutating call to see updated state.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://rundowns',
|
||||
name: 'project-rundowns',
|
||||
title: 'All rundowns in the project',
|
||||
description: 'List of every rundown stored in the current project file, plus the ID of the one currently loaded.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://project/info',
|
||||
name: 'project-info',
|
||||
title: 'Project metadata',
|
||||
description: 'Project title, description, URL, info, logo, and custom header fields.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://project/custom-fields',
|
||||
name: 'project-custom-fields',
|
||||
title: 'Custom field definitions',
|
||||
description:
|
||||
'Map of custom field keys to their label, type, and colour. Events reference these keys in their `custom` object.',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
{
|
||||
uri: 'ontime://docs',
|
||||
name: 'ontime-docs',
|
||||
title: 'Ontime documentation index',
|
||||
description:
|
||||
'Curated index of Ontime documentation topics with direct links to https://docs.getontime.no. Read this when you need to understand a concept in more depth or want to point the user to official documentation.',
|
||||
mimeType: 'text/markdown',
|
||||
},
|
||||
];
|
||||
|
||||
const RESOURCE_READERS: Record<string, { mimeType: string; read: () => string }> = {
|
||||
'ontime://schema': { mimeType: 'text/markdown', read: () => ONTIME_SCHEMA_MARKDOWN },
|
||||
'ontime://docs': { mimeType: 'text/markdown', read: () => ONTIME_DOCS_MARKDOWN },
|
||||
'ontime://rundown/current': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getCurrentRundown()),
|
||||
},
|
||||
'ontime://rundowns': {
|
||||
mimeType: 'application/json',
|
||||
read: () => {
|
||||
const rundowns = normalisedToRundownArray(getDataProvider().getProjectRundowns());
|
||||
const loaded = getCurrentRundown().id;
|
||||
return JSON.stringify({ loaded, rundowns });
|
||||
},
|
||||
},
|
||||
'ontime://project/info': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getProjectData()),
|
||||
},
|
||||
'ontime://project/custom-fields': {
|
||||
mimeType: 'application/json',
|
||||
read: () => JSON.stringify(getProjectCustomFields()),
|
||||
},
|
||||
};
|
||||
|
||||
export function handleReadResource(uri: string): ReadResourceResult {
|
||||
const reader = RESOURCE_READERS[uri];
|
||||
if (!reader) throw new Error(`Unknown resource URI: ${uri}`);
|
||||
return { contents: [{ uri, mimeType: reader.mimeType, text: reader.read() }] };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import express from 'express';
|
||||
import type { Request, Response, Router } from 'express';
|
||||
import { LogOrigin } from 'ontime-types';
|
||||
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { createMcpServer } from './mcp.server.js';
|
||||
|
||||
export const mcpRouter: Router = express.Router();
|
||||
|
||||
mcpRouter.post('/', async (req, res) => {
|
||||
// A new Server instance is created per request — required for stateless mode where
|
||||
// each POST is independent and concurrent requests must not share transport state.
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // stateless: no session tracking
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
res.on('close', () => transport.close());
|
||||
try {
|
||||
const server = createMcpServer();
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req as IncomingMessage, res as ServerResponse, req.body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(LogOrigin.Server, `MCP request failed: ${message}`);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message }, id: req.body?.id ?? null });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stateless mode: GET (SSE) and DELETE (session teardown) are not applicable.
|
||||
// All MCP interactions happen via POST in a single request/response cycle.
|
||||
const methodNotAllowed = (_req: Request, res: Response) =>
|
||||
void res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null });
|
||||
|
||||
mcpRouter.get('/', methodNotAllowed);
|
||||
mcpRouter.delete('/', methodNotAllowed);
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Agent-facing Ontime MCP documentation.
|
||||
*
|
||||
* Tool field schemas (EVENT_TIMER_FIELDS, EVENT_WRITABLE_FIELDS) and the schema resource
|
||||
* (ONTIME_SCHEMA_MARKDOWN) are maintained here so tool descriptions, prompt guidance,
|
||||
* and the agent-readable reference document use the same wording.
|
||||
*
|
||||
* Canonical type definitions live in packages/types/src/definitions/core/OntimeEntry.ts.
|
||||
* Keep this file concise and update it when MCP-exposed fields change.
|
||||
*/
|
||||
|
||||
// ---- Shared event field JSON schemas ----
|
||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
||||
|
||||
export const EVENT_TIMER_FIELDS = {
|
||||
timerType: {
|
||||
type: 'string',
|
||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
||||
},
|
||||
endAction: {
|
||||
type: 'string',
|
||||
enum: ['none', 'load-next', 'play-next'],
|
||||
description:
|
||||
'Action when the event ends. Defaults to none. load-next and play-next create playback automations (load-next arms the next event, play-next auto-starts it) that remove operator control between events and can surprise operators. Only set load-next or play-next when the user explicitly asks for automatic cueing or chaining; otherwise omit this field and leave it as none. Do not add end actions just because events are back-to-back.',
|
||||
},
|
||||
linkStart: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
||||
},
|
||||
countToEnd: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
||||
},
|
||||
timeStrategy: {
|
||||
type: 'string',
|
||||
enum: ['lock-duration', 'lock-end'],
|
||||
description:
|
||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||
},
|
||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
||||
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
||||
} as const;
|
||||
|
||||
export const EVENT_WRITABLE_FIELDS = {
|
||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
||||
title: { type: 'string', description: 'Event title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||
colour: {
|
||||
type: 'string',
|
||||
description: 'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use',
|
||||
},
|
||||
skip: { type: 'boolean', description: 'If true, event is skipped during playback' },
|
||||
flag: {
|
||||
type: 'boolean',
|
||||
description: 'Mark the event as a critical operational marker — use sparingly for maximum impact',
|
||||
},
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description:
|
||||
'Custom field values keyed by existing project field key, e.g. { "camera": "CAM 2" }. Get available keys with ontime_get_custom_fields. Adding new custom field definitions is a separate project-level operation.',
|
||||
},
|
||||
...EVENT_TIMER_FIELDS,
|
||||
} as const;
|
||||
|
||||
export const RUNDOWN_TARGET_FIELD = {
|
||||
rundownId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ---- Agent-readable schema document ----
|
||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
||||
// before issuing tool calls.
|
||||
|
||||
export const ONTIME_SCHEMA_MARKDOWN = `# Ontime data model
|
||||
|
||||
A concise reference for how Ontime structures rundowns, events, and related data.
|
||||
|
||||
## Rundown
|
||||
|
||||
A rundown is an ordered list of entries rendered as a show schedule. A project can contain multiple rundowns; one is "loaded" at a time.
|
||||
|
||||
\`\`\`
|
||||
Rundown {
|
||||
id: string
|
||||
title: string
|
||||
order: EntryId[] // top-level entry order
|
||||
flatOrder: EntryId[] // includes entries nested in groups
|
||||
entries: { [id: EntryId]: OntimeEntry }
|
||||
revision: number
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Entries
|
||||
|
||||
There are four entry types discriminated by \`type\`:
|
||||
|
||||
### \`event\` — OntimeEvent (a timed show item)
|
||||
\`\`\`
|
||||
{
|
||||
type: 'event'
|
||||
id: EntryId
|
||||
cue: string // human-facing cue label
|
||||
title: string
|
||||
note: string
|
||||
colour: string // hex, e.g. "#4A90D9"
|
||||
timeStart: number // ms from midnight (09:00 = 32400000)
|
||||
timeEnd: number // ms from midnight
|
||||
duration: number // ms (= timeEnd - timeStart)
|
||||
delay: number // delay accumulated from rundown delay entries
|
||||
dayOffset: number // runtime calculated day offset from the rundown start schedule, increments when the rundown crosses midnight
|
||||
gap: number // schedule gap between sequential playable events; negative gap means overlap
|
||||
timerType: 'count-down' | 'count-up' | 'clock' | 'none'
|
||||
endAction: 'none' | 'load-next' | 'play-next' // load-next/play-next create playback automations; leave as none unless the user explicitly asks for cueing/chaining
|
||||
linkStart: boolean // chain start to previous event's end
|
||||
countToEnd: boolean // advanced mode: counts to scheduled timeEnd instead of duration; confirm before enabling
|
||||
skip: boolean // event is skipped during playback
|
||||
flag: boolean // critical operational marker, highlighted to operators
|
||||
timeStrategy: 'lock-duration' | 'lock-end'
|
||||
timeWarning: number // ms before end to trigger 'warning' state
|
||||
timeDanger: number // ms before end to trigger 'danger' state
|
||||
custom: { [key: string]: string } // custom field values
|
||||
triggers: Trigger[] // automation trigger references
|
||||
parent: EntryId | null // parent group, when nested
|
||||
revision: number // entry revision
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### \`delay\` — OntimeDelay (schedule shift applied to following events)
|
||||
\`\`\`
|
||||
{ type: 'delay', id, duration: number, parent: EntryId | null }
|
||||
\`\`\`
|
||||
|
||||
### \`group\` — OntimeGroup (nested container of entries)
|
||||
\`\`\`
|
||||
{
|
||||
type: 'group'
|
||||
id: EntryId
|
||||
title: string
|
||||
colour: string
|
||||
note: string
|
||||
entries: EntryId[]
|
||||
targetDuration: number | null
|
||||
custom: { [key: string]: string }
|
||||
timeStart: number | null // calculated from nested entries (runtime)
|
||||
timeEnd: number | null // calculated from nested entries (runtime)
|
||||
duration: number // calculated from nested entries (runtime)
|
||||
isFirstLinked: boolean // whether the first nested event is linked (runtime)
|
||||
revision: number
|
||||
}
|
||||
\`\`\`
|
||||
MCP entry creation accepts title plus optional colour, note, custom values and targetDuration for groups. In batch creation, a group may also include \`children\` to create child events, milestones or delays inside the group. Groups cannot be nested.
|
||||
|
||||
For existing entries, use \`ontime_group_entries\` to create a group from top-level non-group entries, and \`ontime_ungroup_entry\` to dissolve a group back into top-level entries. Use \`ontime_reorder_entry\` only for targeted moves into, out of, or around an existing group.
|
||||
|
||||
### \`milestone\` — OntimeMilestone (marker with no timer)
|
||||
\`\`\`
|
||||
{ type: 'milestone', id, cue, title, note, colour, custom, parent: EntryId | null }
|
||||
\`\`\`
|
||||
|
||||
## Time format
|
||||
Ontime stores time values as **milliseconds from midnight (local)**. Convert user-facing times before calling tools: \`10:30\` means \`37800000\`, and a duration like \`45 min\` means \`2700000\`.
|
||||
|
||||
\`timeEnd\` may be lower than \`timeStart\` when an event crosses midnight; duration is calculated across the day boundary.
|
||||
|
||||
Examples:
|
||||
- 09:00:00 = 32400000
|
||||
- 09:30:00 = 34200000
|
||||
- 14:15:00 = 51300000
|
||||
- Duration of 45 min = 2700000
|
||||
|
||||
## Custom fields
|
||||
Custom fields are project-scoped definitions. Get definitions at \`ontime://project/custom-fields\` or with \`ontime_get_custom_fields\`.
|
||||
Events, milestones and groups store values at \`entry.custom[fieldKey]\`.
|
||||
|
||||
Managing field definitions:
|
||||
- Read: \`ontime_get_custom_fields\` — returns \`{ [key]: { label, type, colour } }\`
|
||||
- Create: \`ontime_create_custom_field { label, type, colour }\` — key is auto-derived from label (spaces → underscores). Confirm the derived key before creating. Returns \`{ key, customFields }\`.
|
||||
- Rename/recolour: \`ontime_update_custom_field { key, label?, colour? }\` — renaming the label changes the derived key and cascades to all entry references across all rundowns. Type cannot be changed.
|
||||
- Delete: \`ontime_delete_custom_field { key }\` — removes the field definition and its values from every entry in all rundowns. Destructive, confirm first.
|
||||
|
||||
When setting \`custom\` values on entries, only use existing field keys. If a key does not exist, create it with \`ontime_create_custom_field\` first.
|
||||
Show the existing field list before creating to avoid duplicates such as \`Cam\`, \`camera\`, and \`Cameras\`.
|
||||
|
||||
## Targeting rundowns
|
||||
Entry read/write tools accept an optional \`rundownId\`.
|
||||
- Omit \`rundownId\` to target the currently loaded live rundown.
|
||||
- Pass a rundown ID from \`ontime_list_rundowns\` to edit a background rundown without loading it.
|
||||
- Loading a rundown with \`ontime_load_rundown\` switches the live rundown and resets runtime state; do not load a rundown just to edit it in the background.
|
||||
|
||||
## Playback states (runtime only)
|
||||
\`'stop' | 'play' | 'pause' | 'armed' | 'roll'\`
|
||||
When playback is not \`stop\`, mutations to the loaded rundown affect the live rundown immediately, so confirm with the user before editing. Mutations to a background rundown using \`rundownId\` do not interrupt playback.
|
||||
|
||||
## Available resources
|
||||
- \`ontime://schema\` — this document
|
||||
- \`ontime://rundown/current\` — the currently loaded rundown (JSON)
|
||||
- \`ontime://rundowns\` — all rundowns in the project (JSON)
|
||||
- \`ontime://project/info\` — project metadata (JSON)
|
||||
- \`ontime://project/custom-fields\` — custom field definitions (JSON)
|
||||
- \`ontime://docs\` — index of Ontime documentation topics with URLs
|
||||
|
||||
## Further reading
|
||||
Full Ontime documentation: **https://docs.getontime.no**
|
||||
`;
|
||||
|
||||
// ---- Documentation index ----
|
||||
// Served at ontime://docs. Agents read this when they need to point users to official docs.
|
||||
|
||||
export const ONTIME_DOCS_MARKDOWN = `# Ontime Documentation Index
|
||||
|
||||
Main site: https://docs.getontime.no
|
||||
|
||||
## Getting started
|
||||
- Installation & setup: https://docs.getontime.no/installation/
|
||||
|
||||
## Core concepts
|
||||
- Rundown: https://docs.getontime.no/concepts/rundown/
|
||||
- Timer types (count-down, count-up, clock, none): https://docs.getontime.no/concepts/timer/
|
||||
- Time entry format: https://docs.getontime.no/concepts/time-entry/
|
||||
- Event actions (end action, link start): https://docs.getontime.no/concepts/event-actions/
|
||||
- Delays and blocks: https://docs.getontime.no/concepts/delays-and-blocks/
|
||||
|
||||
## Features
|
||||
- Custom fields: https://docs.getontime.no/features/custom-fields/
|
||||
- Automations (triggers, filters, outputs): https://docs.getontime.no/features/automations/
|
||||
- URL presets / shared views: https://docs.getontime.no/features/url-presets/
|
||||
- HTTP Integration: https://docs.getontime.no/api/http/
|
||||
- OSC Integration: https://docs.getontime.no/api/osc/
|
||||
|
||||
## API reference
|
||||
- REST API overview: https://docs.getontime.no/api/
|
||||
- WebSocket events: https://docs.getontime.no/api/websocket/
|
||||
`;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
type CallToolResult,
|
||||
type ListToolsResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
import { PROMPT_DEFINITIONS, handleGetPrompt } from './mcp.prompts.js';
|
||||
import { RESOURCE_DEFINITIONS, handleReadResource } from './mcp.resources.js';
|
||||
import { TOOL_DEFINITIONS, handleToolCall } from './mcp.tools.js';
|
||||
|
||||
export function createMcpServer(): Server {
|
||||
const server = new Server(
|
||||
{ name: 'ontime-mcp-server', version: '1.0.0' },
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
|
||||
server.setRequestHandler(
|
||||
ListToolsRequestSchema,
|
||||
async (): Promise<ListToolsResult> => ({
|
||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||
}),
|
||||
);
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
return handleToolCall(name, args as Record<string, unknown>);
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPT_DEFINITIONS }));
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
||||
const { name, arguments: args = {} } = request.params;
|
||||
return handleGetPrompt(name, args as Record<string, string>);
|
||||
});
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: RESOURCE_DEFINITIONS }));
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => handleReadResource(request.params.uri));
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
InsertOptions,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
addEntry,
|
||||
batchEditEntries,
|
||||
createCustomField,
|
||||
deleteCustomField,
|
||||
deleteEntries,
|
||||
editCustomField,
|
||||
editEntry,
|
||||
groupEntries,
|
||||
reorderEntry,
|
||||
ungroupEntries,
|
||||
} from '../api-data/rundown/rundown.service.js';
|
||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
export type EventFieldArgs = Partial<
|
||||
Pick<
|
||||
OntimeEvent,
|
||||
| 'cue'
|
||||
| 'title'
|
||||
| 'note'
|
||||
| 'colour'
|
||||
| 'skip'
|
||||
| 'flag'
|
||||
| 'custom'
|
||||
| 'timerType'
|
||||
| 'endAction'
|
||||
| 'linkStart'
|
||||
| 'countToEnd'
|
||||
| 'timeStrategy'
|
||||
| 'timeWarning'
|
||||
| 'timeDanger'
|
||||
| 'timeStart'
|
||||
| 'timeEnd'
|
||||
| 'duration'
|
||||
>
|
||||
>;
|
||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
||||
|
||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
||||
export type TargetRundownArgs = { rundownId?: string };
|
||||
export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` };
|
||||
export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEntryArgs[] };
|
||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
||||
|
||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||
return args.rundownId ?? getCurrentRundownId();
|
||||
}
|
||||
|
||||
function getTargetMeta(rundownId: string) {
|
||||
const loaded = rundownId === getCurrentRundownId();
|
||||
return { rundownId, loaded };
|
||||
}
|
||||
|
||||
export function getRundownById(rundownId?: string): Readonly<Rundown> {
|
||||
const targetId = rundownId ?? getCurrentRundownId();
|
||||
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
||||
}
|
||||
|
||||
export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined {
|
||||
const rundown = getRundownById(args.rundownId);
|
||||
if (args.id) {
|
||||
return rundown.entries[args.id];
|
||||
}
|
||||
if (args.cue) {
|
||||
return Object.values(rundown.entries).find((entry) => 'cue' in entry && entry.cue === args.cue);
|
||||
}
|
||||
}
|
||||
|
||||
export function toRundownList(projectRundowns: Readonly<ProjectRundowns>) {
|
||||
return { loaded: getCurrentRundownId(), rundowns: normalisedToRundownArray(projectRundowns) };
|
||||
}
|
||||
|
||||
export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['custom'] | undefined>) {
|
||||
const customFields = getProjectCustomFields();
|
||||
const knownKeys = new Set(Object.keys(customFields));
|
||||
const unknownKeys = new Set<string>();
|
||||
|
||||
for (const custom of customValues) {
|
||||
if (!custom) continue;
|
||||
for (const key of Object.keys(custom)) {
|
||||
if (!knownKeys.has(key)) {
|
||||
unknownKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unknownKeys.size > 0) {
|
||||
const missing = [...unknownKeys].join(', ');
|
||||
const available = Object.keys(customFields);
|
||||
const hint = available.length > 0 ? `Available keys: ${available.join(', ')}.` : 'No custom fields are defined yet.';
|
||||
throw new Error(
|
||||
`Unknown custom field key(s): ${missing}. ${hint} ` +
|
||||
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
|
||||
`the key is auto-derived from the label (spaces → underscores, e.g. label "Camera" → key "Camera"). ` +
|
||||
`Call ontime_get_custom_fields to list existing keys.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
||||
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
||||
const { type = SupportedEntry.Event, after, before } = args;
|
||||
|
||||
switch (type) {
|
||||
case SupportedEntry.Delay:
|
||||
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
|
||||
case SupportedEntry.Milestone: {
|
||||
const { cue, title, note, colour, custom } = args;
|
||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
|
||||
}
|
||||
case SupportedEntry.Group:
|
||||
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
||||
return { type: SupportedEntry.Group, title: args.title, after, before };
|
||||
case SupportedEntry.Event: {
|
||||
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
|
||||
return { type: SupportedEntry.Event, ...eventFields };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid entry type: ${String(type)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function toGroupPatch(args: CreateEntryArgs, id: EntryId): PatchWithId<OntimeGroup> | null {
|
||||
const patch: PatchWithId<OntimeGroup> = { id };
|
||||
|
||||
if (args.title !== undefined) patch.title = args.title;
|
||||
if (args.note !== undefined) patch.note = args.note;
|
||||
if (args.colour !== undefined) patch.colour = args.colour;
|
||||
if (args.targetDuration !== undefined) patch.targetDuration = args.targetDuration;
|
||||
if (args.custom !== undefined) patch.custom = args.custom;
|
||||
|
||||
return Object.keys(patch).length > 1 ? patch : null;
|
||||
}
|
||||
|
||||
async function updateCreatedGroup(rundownId: string, args: CreateEntryArgs, entry: OntimeEntry): Promise<OntimeEntry> {
|
||||
if (args.type !== SupportedEntry.Group) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const patch = toGroupPatch(args, entry.id);
|
||||
if (!patch) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return editEntry(rundownId, patch);
|
||||
}
|
||||
|
||||
export async function createEntryForMcp(args: CreateEntryArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const createdEntry = await addEntry(rundownId, toEntryPayload(args));
|
||||
const entry = await updateCreatedGroup(rundownId, args, createdEntry);
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function updateEntryForMcp(args: UpdateEntryArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const { rundownId: _rundownId, ...patch } = args;
|
||||
const entry = await editEntry(rundownId, patch as PatchWithId);
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await deleteEntries(rundownId, args.ids);
|
||||
return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function reorderEntryForMcp(
|
||||
args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' },
|
||||
) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
||||
return { target: getTargetMeta(rundownId), order: rundown.order };
|
||||
}
|
||||
|
||||
export async function groupEntriesForMcp(args: GroupEntriesArgs) {
|
||||
assertKnownCustomFields(args.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = getRundownById(rundownId);
|
||||
|
||||
if (!Array.isArray(args.ids) || args.ids.length === 0) {
|
||||
throw new Error('Provide at least one entry ID to group.');
|
||||
}
|
||||
|
||||
for (const id of args.ids) {
|
||||
const entry = rundown.entries[id];
|
||||
if (!entry) {
|
||||
throw new Error(`No entry with id ${id}`);
|
||||
}
|
||||
if (entry.type === SupportedEntry.Group) {
|
||||
throw new Error(`Cannot group group entry ${id}. Groups cannot be nested.`);
|
||||
}
|
||||
if ('parent' in entry && entry.parent) {
|
||||
throw new Error(`Cannot group nested entry ${id}. Move it out of its group first.`);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRundown = await groupEntries(rundownId, args.ids);
|
||||
const group = Object.values(updatedRundown.entries).find(
|
||||
(entry): entry is OntimeGroup =>
|
||||
entry.type === SupportedEntry.Group && args.ids.every((id) => entry.entries.includes(id)),
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group operation did not create a group for the provided entries.');
|
||||
}
|
||||
|
||||
const patch = toGroupPatch(args, group.id);
|
||||
const entry = patch ? await editEntry(rundownId, patch) : group;
|
||||
|
||||
return { target: getTargetMeta(rundownId), entry, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = getRundownById(rundownId);
|
||||
const entry = rundown.entries[args.id];
|
||||
|
||||
if (!entry || entry.type !== SupportedEntry.Group) {
|
||||
throw new Error(`Group with ID ${args.id} not found or is not a group`);
|
||||
}
|
||||
|
||||
const updatedRundown = await ungroupEntries(rundownId, args.id);
|
||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function batchCreateEntriesForMcp(
|
||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
||||
) {
|
||||
const { entries = [], after } = args;
|
||||
validateBatchCreateEntries(entries);
|
||||
const allEntries = flattenBatchCreateEntries(entries);
|
||||
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
let previousId = after;
|
||||
const created: OntimeEntry[] = [];
|
||||
|
||||
for (const entryArgs of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
|
||||
created.push(...entry.created);
|
||||
previousId = entry.entry.id;
|
||||
}
|
||||
|
||||
return { target: getTargetMeta(rundownId), created };
|
||||
}
|
||||
|
||||
function flattenBatchCreateEntries(entries: BatchCreateEntryArgs[]): BatchCreateEntryArgs[] {
|
||||
return entries.flatMap((entry) => [entry, ...flattenBatchCreateEntries(entry.children ?? [])]);
|
||||
}
|
||||
|
||||
function validateBatchCreateEntries(entries: BatchCreateEntryArgs[], insideGroup = false) {
|
||||
for (const entry of entries) {
|
||||
if (insideGroup && entry.type === SupportedEntry.Group) {
|
||||
throw new Error('Cannot create a group inside another group.');
|
||||
}
|
||||
|
||||
if (entry.children?.length && entry.type !== SupportedEntry.Group) {
|
||||
throw new Error('Only group entries can have children.');
|
||||
}
|
||||
|
||||
validateBatchCreateEntries(entry.children ?? [], entry.type === SupportedEntry.Group);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBatchEntry(
|
||||
rundownId: string,
|
||||
entryArgs: BatchCreateEntryArgs,
|
||||
previousId?: EntryId,
|
||||
parentId?: EntryId,
|
||||
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
||||
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
||||
throw new Error('Cannot create a group inside another group.');
|
||||
}
|
||||
|
||||
const { children: _children, ...createArgs } = entryArgs;
|
||||
const payload = toEntryPayload(createArgs);
|
||||
const insertOptions = {
|
||||
...(previousId ? { after: previousId } : {}),
|
||||
...(parentId ? { parent: parentId } : {}),
|
||||
};
|
||||
|
||||
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
||||
const entry = await updateCreatedGroup(rundownId, createArgs, createdEntry);
|
||||
const created = [entry];
|
||||
|
||||
if (entryArgs.children?.length) {
|
||||
if (entry.type !== SupportedEntry.Group) {
|
||||
throw new Error('Only group entries can have children.');
|
||||
}
|
||||
|
||||
let previousChildId: EntryId | undefined;
|
||||
for (const childArgs of entryArgs.children) {
|
||||
// eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one
|
||||
const child = await createBatchEntry(rundownId, childArgs, previousChildId, entry.id);
|
||||
created.push(...child.created);
|
||||
previousChildId = child.entry.id;
|
||||
}
|
||||
}
|
||||
|
||||
return { entry, created };
|
||||
}
|
||||
|
||||
export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) {
|
||||
assertKnownCustomFields(args.data.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
||||
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
|
||||
const updated = await createCustomField({ label: args.label, type: args.type, colour: args.colour });
|
||||
const key = Object.keys(updated).find((k) => updated[k].label === args.label) ?? args.label;
|
||||
return { key, customFields: updated };
|
||||
}
|
||||
|
||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
||||
return { customFields: updated };
|
||||
}
|
||||
|
||||
export async function deleteCustomFieldForMcp(args: { key: string }) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
||||
return { customFields: updated };
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { EntryId, ProjectData } from 'ontime-types';
|
||||
|
||||
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js';
|
||||
import {
|
||||
createNewRundown,
|
||||
deleteRundown,
|
||||
duplicateExistingRundown,
|
||||
loadRundown,
|
||||
renameRundown,
|
||||
} from '../api-data/rundown/rundown.service.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { makeNewProject } from '../models/dataModel.js';
|
||||
import {
|
||||
createProjectWithPatch,
|
||||
deleteProjectFile,
|
||||
duplicateProjectFile,
|
||||
getProjectList,
|
||||
loadProjectFile,
|
||||
renameProjectFile,
|
||||
} from '../services/project-service/ProjectService.js';
|
||||
import { getState } from '../stores/runtimeState.js';
|
||||
import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
||||
import {
|
||||
batchCreateEntriesForMcp,
|
||||
batchUpdateEntriesForMcp,
|
||||
createCustomFieldForMcp,
|
||||
createEntryForMcp,
|
||||
deleteCustomFieldForMcp,
|
||||
deleteEntriesForMcp,
|
||||
findEntry,
|
||||
getRundownById,
|
||||
groupEntriesForMcp,
|
||||
reorderEntryForMcp,
|
||||
toRundownList,
|
||||
ungroupEntryForMcp,
|
||||
updateCustomFieldForMcp,
|
||||
updateEntryForMcp,
|
||||
type BatchCreateEntryArgs,
|
||||
type CreateEntryArgs,
|
||||
type EntryFieldArgs,
|
||||
type GroupEntriesArgs,
|
||||
type TargetRundownArgs,
|
||||
type UngroupEntryArgs,
|
||||
type UpdateEntryArgs,
|
||||
} from './mcp.service.js';
|
||||
|
||||
// Graceful truncation to keep tool responses within typical MCP context windows
|
||||
const CHARACTER_LIMIT = 25_000;
|
||||
|
||||
// ---- MCP tool annotation presets ----
|
||||
// https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
|
||||
const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||
const WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false } as const;
|
||||
const WRITE_IDEM = { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false } as const;
|
||||
const WRITE_DESTRUCTIVE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
// ---- Tool definitions ----
|
||||
export const TOOL_DEFINITIONS = [
|
||||
// --- Rundown read ---
|
||||
{
|
||||
name: 'ontime_get_rundown',
|
||||
description:
|
||||
'Get a rundown. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual entries with ontime_get_entry.',
|
||||
inputSchema: { type: 'object', properties: { ...RUNDOWN_TARGET_FIELD } },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_rundown_metadata',
|
||||
description:
|
||||
'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_entry',
|
||||
description:
|
||||
'Get a single entry by id or cue. Provide either id or cue (not both). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns the full entry object.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Entry ID (from rundown.entries key or entry.id)' },
|
||||
cue: { type: 'string', description: 'Human-facing cue label' },
|
||||
},
|
||||
},
|
||||
annotations: READ,
|
||||
},
|
||||
// --- Rundown mutations ---
|
||||
{
|
||||
name: 'ontime_create_entry',
|
||||
description:
|
||||
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description:
|
||||
'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight (e.g. 09:00 = 32400000)' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: {
|
||||
type: 'number',
|
||||
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
after: { type: 'string', description: 'Insert after this entry ID' },
|
||||
before: { type: 'string', description: 'Insert before this entry ID' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_entry',
|
||||
description:
|
||||
'Update fields of an existing entry (event, milestone, delay or group). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'ID of the entry to update' },
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_entries',
|
||||
description:
|
||||
'Delete one or more entries (events, milestones, delays, or groups). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_reorder_entry',
|
||||
description:
|
||||
'Move an entry to a new position relative to another entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use before/after for sibling reordering; use insert for targeted moves into a group. For grouping several existing top-level entries, prefer ontime_group_entries.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entryId', 'destinationId', 'order'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
entryId: { type: 'string', description: 'ID of the entry to move' },
|
||||
destinationId: { type: 'string', description: 'ID of the target entry (sibling or parent group)' },
|
||||
order: {
|
||||
type: 'string',
|
||||
enum: ['before', 'after', 'insert'],
|
||||
description: 'before/after: place as sibling; insert: place inside a group',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_group_entries',
|
||||
description:
|
||||
'Create a group from existing top-level entries. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Entries must be existing top-level non-group entries; groups cannot be nested. Optional title, note, colour, custom, and targetDuration are applied to the created group.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
|
||||
title: { type: 'string', description: 'Group title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text group note for production notes or references' },
|
||||
colour: { type: 'string', description: 'Hex colour (#RRGGBB) for the group' },
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Custom field values keyed by existing project field key',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Planned length of the group in ms' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_ungroup_entry',
|
||||
description:
|
||||
'Dissolve a group by moving its children to the top level where the group was. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Group entry ID to dissolve' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_create_entries',
|
||||
description:
|
||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entries'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
|
||||
entries: {
|
||||
type: 'array',
|
||||
description: 'Array of entries to create, in desired order',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description: 'Entry type, defaults to event',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
children: {
|
||||
type: 'array',
|
||||
description:
|
||||
'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_update_entries',
|
||||
description:
|
||||
'Apply the same field values to multiple entries by ID. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids', 'data'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to update' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Partial entry fields to apply to every ID',
|
||||
properties: {
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Rundown management ---
|
||||
{
|
||||
name: 'ontime_list_rundowns',
|
||||
description:
|
||||
'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_rundown',
|
||||
description:
|
||||
'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string', description: 'Title for the new rundown' } },
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_rundown',
|
||||
description:
|
||||
'Make a rundown the active rundown. This resets the runtime and clears playback state. If playback is running, confirm the user accepts interrupting the live rundown before calling. To edit a background rundown without interrupting playback, advise using the cuesheet view.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to load' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_rundown',
|
||||
description: 'Rename an existing rundown',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id', 'title'],
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Rundown ID to rename' },
|
||||
title: { type: 'string', description: 'New title' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_rundown',
|
||||
description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to delete' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_rundown',
|
||||
description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } },
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
// --- Timer & project ---
|
||||
{
|
||||
name: 'ontime_get_timer_state',
|
||||
description:
|
||||
'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_project_info',
|
||||
description:
|
||||
'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_project_info',
|
||||
description: 'Update project metadata fields. All fields are optional — only provided fields are updated.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Project title' },
|
||||
description: { type: 'string', description: 'Project description' },
|
||||
url: { type: 'string', description: 'URL shown on viewer pages' },
|
||||
info: { type: 'string', description: 'Info text shown on viewer pages' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_custom_fields',
|
||||
description:
|
||||
'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in entry.custom[key].',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_custom_field',
|
||||
description:
|
||||
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). After creation, use the key in entry.custom when creating or updating entries.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['label', 'type', 'colour'],
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Human-readable label (alphanumeric with spaces, e.g. "Camera"). Determines the key — ask the user to confirm before creating to avoid duplicates like "Cam", "camera", "Cameras".',
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['text', 'image'],
|
||||
description: 'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
|
||||
},
|
||||
colour: {
|
||||
type: 'string',
|
||||
description: 'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet.',
|
||||
},
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_custom_field',
|
||||
description:
|
||||
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
|
||||
label: { type: 'string', description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.' },
|
||||
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_custom_field',
|
||||
description:
|
||||
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Project file management ---
|
||||
{
|
||||
name: 'ontime_list_projects',
|
||||
description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_project',
|
||||
description:
|
||||
'Load a different project file by filename. This swaps the database and reinitialises runtime. If playback is running, confirm the user accepts interrupting the live project before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_project',
|
||||
description:
|
||||
'Create a new project file and switch to it. This swaps the loaded project. If playback is running, confirm the user accepts interrupting the live project before calling. Omit the .json extension — Ontime appends it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' },
|
||||
title: { type: 'string', description: 'Optional project title' },
|
||||
description: { type: 'string', description: 'Optional project description' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_project',
|
||||
description: 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Current filename (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'New filename (with .json extension)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_project',
|
||||
description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Source filename to copy (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' },
|
||||
},
|
||||
},
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_project',
|
||||
description: 'Delete a project file from disk. Fails if the file is currently loaded.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } },
|
||||
},
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ToolName = (typeof TOOL_DEFINITIONS)[number]['name'];
|
||||
|
||||
type ProjectInfoArgs = Partial<Pick<ProjectData, 'title' | 'description' | 'url' | 'info'>>;
|
||||
|
||||
// ---- Response helpers (module-level to avoid re-allocation on every tool call) ----
|
||||
|
||||
const text = (data: unknown): string => JSON.stringify(data);
|
||||
|
||||
export const ok = (data: unknown): CallToolResult => ({ content: [{ type: 'text', text: text(data) }] });
|
||||
|
||||
export const err = (e: unknown): CallToolResult => ({
|
||||
content: [{ type: 'text', text: text({ error: e instanceof Error ? e.message : String(e) }) }],
|
||||
isError: true,
|
||||
});
|
||||
|
||||
// ---- Tool handlers ----
|
||||
// Each handler is a thin translation wrapper: it maps the wire arguments to a typed call
|
||||
// into an existing service and formats the response. Business logic belongs in the services.
|
||||
const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise<CallToolResult>> = {
|
||||
ontime_get_rundown: async (args) => {
|
||||
const targetArgs = args as TargetRundownArgs;
|
||||
const rundown = getRundownById(targetArgs.rundownId);
|
||||
const data = { order: rundown.order, entries: rundown.entries };
|
||||
const serialised = text(data);
|
||||
if (serialised.length > CHARACTER_LIMIT) {
|
||||
return ok({
|
||||
warning: `Rundown too large (${serialised.length} chars) — fetch individual entries with ontime_get_entry. Entry IDs in order: ${rundown.order.join(', ')}`,
|
||||
truncated: true,
|
||||
rundownId: rundown.id,
|
||||
order: rundown.order,
|
||||
});
|
||||
}
|
||||
return ok({ rundownId: rundown.id, ...data });
|
||||
},
|
||||
|
||||
ontime_get_rundown_metadata: async () => ok(getRundownMetadata()),
|
||||
|
||||
ontime_get_entry: async (args) => {
|
||||
const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string };
|
||||
const entry = findEntry(entryArgs);
|
||||
if (entry) return ok(entry);
|
||||
if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`);
|
||||
if (entryArgs.cue) return err(`No entry with cue ${entryArgs.cue}`);
|
||||
return err('Provide id or cue');
|
||||
},
|
||||
|
||||
ontime_create_entry: async (args) => {
|
||||
return ok(await createEntryForMcp(args as CreateEntryArgs));
|
||||
},
|
||||
|
||||
ontime_update_entry: async (args) => {
|
||||
return ok(await updateEntryForMcp(args as UpdateEntryArgs));
|
||||
},
|
||||
|
||||
ontime_delete_entries: async (args) => {
|
||||
return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] }));
|
||||
},
|
||||
|
||||
ontime_reorder_entry: async (args) => {
|
||||
return ok(
|
||||
await reorderEntryForMcp(
|
||||
args as TargetRundownArgs & {
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
ontime_group_entries: async (args) => {
|
||||
return ok(await groupEntriesForMcp(args as GroupEntriesArgs));
|
||||
},
|
||||
|
||||
ontime_ungroup_entry: async (args) => {
|
||||
return ok(await ungroupEntryForMcp(args as UngroupEntryArgs));
|
||||
},
|
||||
|
||||
ontime_batch_create_entries: async (args) => {
|
||||
return ok(
|
||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
||||
);
|
||||
},
|
||||
|
||||
ontime_batch_update_entries: async (args) => {
|
||||
return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }));
|
||||
},
|
||||
|
||||
ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())),
|
||||
|
||||
ontime_create_rundown: async (args) => {
|
||||
const { title } = args as { title: string };
|
||||
return ok(toRundownList(await createNewRundown(title)));
|
||||
},
|
||||
|
||||
ontime_load_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await loadRundown(id)));
|
||||
},
|
||||
|
||||
ontime_rename_rundown: async (args) => {
|
||||
const { id, title } = args as { id: string; title: string };
|
||||
return ok(toRundownList(await renameRundown(id, title)));
|
||||
},
|
||||
|
||||
ontime_delete_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await deleteRundown(id)));
|
||||
},
|
||||
|
||||
ontime_duplicate_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
return ok(toRundownList(await duplicateExistingRundown(id)));
|
||||
},
|
||||
|
||||
ontime_get_timer_state: async () => {
|
||||
const { clock, timer, eventNow, eventNext, offset } = getState();
|
||||
return ok({ clock, timer, eventNow, eventNext, offset });
|
||||
},
|
||||
|
||||
ontime_get_project_info: async () => ok(getProjectData()),
|
||||
|
||||
ontime_update_project_info: async (args) => {
|
||||
const updated = await editCurrentProjectData(args as ProjectInfoArgs);
|
||||
return ok(updated);
|
||||
},
|
||||
|
||||
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
|
||||
|
||||
ontime_create_custom_field: async (args) => {
|
||||
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
|
||||
},
|
||||
|
||||
ontime_update_custom_field: async (args) => {
|
||||
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
|
||||
},
|
||||
|
||||
ontime_delete_custom_field: async (args) => {
|
||||
return ok(await deleteCustomFieldForMcp(args as { key: string }));
|
||||
},
|
||||
|
||||
ontime_list_projects: async () => ok(await getProjectList()),
|
||||
|
||||
ontime_load_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
await loadProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_create_project: async (args) => {
|
||||
const {
|
||||
filename,
|
||||
title = '',
|
||||
description = '',
|
||||
} = args as {
|
||||
filename: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
const project: ProjectData = { ...makeNewProject().project, title, description };
|
||||
const newFileName = await createProjectWithPatch(filename, { project });
|
||||
return ok({ filename: newFileName });
|
||||
},
|
||||
|
||||
ontime_rename_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
await renameProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_duplicate_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
await duplicateProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_delete_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
await deleteProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Tool call dispatcher ----
|
||||
export async function handleToolCall(name: string, args: Record<string, unknown>): Promise<CallToolResult> {
|
||||
const handler = TOOL_HANDLERS[name as ToolName];
|
||||
if (!handler) {
|
||||
return err(`Unknown tool: ${name}`);
|
||||
}
|
||||
try {
|
||||
return await handler(args);
|
||||
} catch (error) {
|
||||
return err(error);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
import { integrationRouter } from './api-integration/integration.router.js';
|
||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||
// Services
|
||||
import { logger } from './classes/Logger.js';
|
||||
@@ -100,6 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
||||
|
||||
// serve static external files
|
||||
app.use(
|
||||
|
||||
@@ -101,9 +101,10 @@ function getCustomFields(): Readonly<CustomFields> {
|
||||
return db.data.customFields;
|
||||
}
|
||||
|
||||
async function setRundown(rundownKey: string, newData: Rundown): Promise<void> {
|
||||
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<ProjectRundowns> {
|
||||
db.data.rundowns[rundownKey] = structuredClone(newData);
|
||||
await persist();
|
||||
return db.data.rundowns;
|
||||
}
|
||||
|
||||
function getSettings(): Readonly<Settings> {
|
||||
|
||||
@@ -6,6 +6,53 @@ test('cuesheet displays events', async ({ page }) => {
|
||||
await expect(page.getByTestId('cuesheet-event').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('cuesheet datagrid does not submit timer cells on tab-out or escape', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
const firstEvent = page.getByTestId('cuesheet-event').first();
|
||||
await expect(firstEvent).toBeVisible();
|
||||
|
||||
const durationCell = firstEvent.getByTestId('cuesheet-cell-duration');
|
||||
|
||||
/**
|
||||
* 1. Tab out of a timer cell should not submit the typed value
|
||||
*/
|
||||
await durationCell.click();
|
||||
const durationInput = durationCell.locator('input');
|
||||
await expect(durationInput).toBeVisible();
|
||||
const originalDuration = await durationInput.inputValue();
|
||||
|
||||
await durationInput.fill('01:00:00');
|
||||
await durationInput.press('Tab');
|
||||
|
||||
// cell should exit edit mode without submitting
|
||||
await expect(durationInput).not.toBeVisible();
|
||||
|
||||
// re-enter edit mode: original value should be unchanged
|
||||
await durationCell.click();
|
||||
// tabbing selects the next input field so we have to click twice to first leave input field and then select
|
||||
await durationCell.click();
|
||||
await expect(durationCell.locator('input')).toHaveValue(originalDuration);
|
||||
await durationCell.locator('input').press('Escape');
|
||||
|
||||
/**
|
||||
* 2. Escape on a timer cell should not submit, should revert to original value
|
||||
*/
|
||||
await durationCell.click();
|
||||
await expect(durationCell.locator('input')).toBeVisible();
|
||||
|
||||
await durationCell.locator('input').fill('02:00:00');
|
||||
await durationCell.locator('input').press('Escape');
|
||||
|
||||
// cell should exit edit mode without submitting
|
||||
await expect(durationCell.locator('input')).not.toBeVisible();
|
||||
|
||||
// re-enter edit mode: original value should be unchanged
|
||||
await durationCell.click();
|
||||
await expect(durationCell.locator('input')).toHaveValue(originalDuration);
|
||||
await durationCell.locator('input').press('Escape');
|
||||
});
|
||||
|
||||
test('cuesheet datagrid keeps keyboard focus flow while editing text cells', async ({ page }) => {
|
||||
await page.goto('/cuesheet');
|
||||
|
||||
@@ -55,3 +102,27 @@ test('cuesheet datagrid keeps keyboard focus flow while editing text cells', asy
|
||||
await expect(cueEditor).not.toBeFocused();
|
||||
await expect(cueEditor).toHaveValue(cueBeforeCancel);
|
||||
});
|
||||
|
||||
test('cuesheet background edit from empty state', async ({ page }) => {
|
||||
// create an empty rundown
|
||||
await page.goto('/editor');
|
||||
await page.getByRole('button', { name: 'Toggle settings' }).click();
|
||||
await page.getByRole('button', { name: 'Manage rundowns' }).click();
|
||||
await page.getByRole('button', { name: 'New' }).nth(1).click();
|
||||
const emptyName = `empty-${Date.now()}`;
|
||||
await page.getByRole('textbox', { name: 'Rundown title' }).fill(emptyName);
|
||||
await page.getByRole('button', { name: 'Create rundown' }).click();
|
||||
|
||||
// edit it in the cuesheet
|
||||
await page.getByRole('row', { name: '0 empty-' }).getByTestId('rundown_menu').click();
|
||||
await page.getByText('Edit in cuesheet').click();
|
||||
|
||||
// expect to see and empty screen
|
||||
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
|
||||
|
||||
// create 1 event
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
|
||||
// and expect to find it
|
||||
await expect(page.getByTestId('cuesheet-event')).toBeVisible();
|
||||
});
|
||||
|
||||
+13
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -17,7 +17,7 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"engines": {
|
||||
"node": "~22",
|
||||
"pnpm": "~10"
|
||||
"pnpm": "~11"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -32,16 +32,16 @@
|
||||
"dist-win": "turbo run dist-win --filter=ontime-electron",
|
||||
"dist-mac": "turbo run dist-mac --filter=ontime-electron",
|
||||
"dist-linux": "turbo run dist-linux --filter=ontime-electron",
|
||||
"e2e": "turbo run build --filter=ontime-ui && pnpm clear-temp && cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts",
|
||||
"e2e:ui": "cross-env NODE_ENV=development cross-env DEBUG=pw:webserver npx playwright test --ui -c playwright.config.ts",
|
||||
"e2e:i": "npx playwright codegen",
|
||||
"e2e": "turbo run build --filter=ontime-ui && pnpm clear-temp && cross-env DEBUG=pw:webserver pnpm playwright test -c playwright.config.ts",
|
||||
"e2e:ui": "cross-env NODE_ENV=development cross-env DEBUG=pw:webserver pnpm playwright test --ui -c playwright.config.ts",
|
||||
"e2e:i": "pnpm playwright codegen",
|
||||
"clear-temp": "rimraf e2e/tests/fixtures/tmp",
|
||||
"test": "turbo run test:pipeline",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@types/node": "catalog:",
|
||||
"cross-env": "^7.0.3",
|
||||
"oxfmt": "^0.42.0",
|
||||
@@ -51,5 +51,11 @@
|
||||
"turbo": "2.8.20",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||
"devEngines": {
|
||||
"runtime": {
|
||||
"name": "node",
|
||||
"version": "22.22.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.8.0",
|
||||
"version": "4.9.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -41,6 +41,7 @@ type RefetchPacket = {
|
||||
payload: {
|
||||
target: RefetchKey;
|
||||
revision: MaybeNumber;
|
||||
rundownId?: string; // undefined means refetch all rundowns
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum RefetchKey {
|
||||
All = 'all',
|
||||
CustomFields = 'custom-fields',
|
||||
ProjectData = 'project-data',
|
||||
ProjectRundowns = 'project-rundowns',
|
||||
Report = 'report',
|
||||
Rundown = 'rundown',
|
||||
UrlPresets = 'url-presets',
|
||||
|
||||
Generated
+590
-771
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -9,8 +9,10 @@ catalog:
|
||||
typescript: 6.0.2
|
||||
vitest: 4.0.17
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
|
||||
useNodeVersion: 22.22.2
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@sentry/cli': false
|
||||
core-js: false
|
||||
electron: true
|
||||
electron-winstaller: false
|
||||
esbuild: true
|
||||
|
||||
Reference in New Issue
Block a user