refactor: improve groups and time end

This commit is contained in:
Carlos Valente
2026-06-28 09:08:24 +02:00
parent c6cf9f2bf7
commit 9a6feed49b
5 changed files with 494 additions and 22 deletions
@@ -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'] });
});
});
+20 -7
View File
@@ -50,14 +50,14 @@ 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 events in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
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. Groups are created with a title only — use ontime_update_entry afterwards to set \`colour\`, \`note\`, \`custom\`, or \`targetDuration\`.
- 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.
@@ -72,6 +72,12 @@ Timer type (timerType):
- \`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):
- \`none\` (default): stops at end; operator must manually start the next event.
- \`load-next\`: pre-arms the next event; operator triggers start. Use when a human handoff is needed.
@@ -112,8 +118,10 @@ Strategy:
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 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 }.
6. 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.
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.
@@ -149,6 +157,7 @@ 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.
@@ -174,14 +183,18 @@ Steps:
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. Compute the target arrangement as a sequence of moves.
6. For each event that needs to move, call ontime_reorder_entry:
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)
7. Call ontime_get_rundown again to confirm the new order.
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.
+9 -3
View File
@@ -28,7 +28,11 @@ export const EVENT_TIMER_FIELDS = {
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: 'Timer counts toward the scheduled end time rather than elapsed time' },
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'],
@@ -114,7 +118,7 @@ There are four entry types discriminated by \`type\`:
timerType: 'count-down' | 'count-up' | 'clock' | 'none'
endAction: 'none' | 'load-next' | 'play-next'
linkStart: boolean // chain start to previous event's end
countToEnd: boolean // timer counts to planned end time
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'
@@ -150,7 +154,9 @@ There are four entry types discriminated by \`type\`:
revision: number
}
\`\`\`
Groups are created with a title only — set colour, note, custom values and targetDuration with an update after creation.
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)
\`\`\`
+147 -8
View File
@@ -19,7 +19,9 @@ import {
batchEditEntries,
deleteEntries,
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';
@@ -53,7 +55,10 @@ export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colou
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();
@@ -126,10 +131,36 @@ export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
}
}
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 entry = await addEntry(rundownId, toEntryPayload(args));
const createdEntry = await addEntry(rundownId, toEntryPayload(args));
const entry = await updateCreatedGroup(rundownId, args, createdEntry);
return { target: getTargetMeta(rundownId), entry };
}
@@ -155,26 +186,134 @@ export async function reorderEntryForMcp(
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: CreateEntryArgs[]; after?: EntryId },
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
) {
const { entries = [], after } = args;
assertKnownCustomFields(...entries.map((entry) => entry.custom));
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) {
const payload = toEntryPayload(entryArgs);
// eslint-disable-next-line no-await-in-loop -- each entry chains after the previously created one
const entry = await addEntry(rundownId, previousId ? { ...payload, after: previousId } : payload);
created.push(entry);
previousId = entry.id;
// 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);
+63 -4
View File
@@ -29,12 +29,17 @@ import {
deleteEntriesForMcp,
findEntry,
getRundownById,
groupEntriesForMcp,
reorderEntryForMcp,
toRundownList,
ungroupEntryForMcp,
updateEntryForMcp,
type BatchCreateEntryArgs,
type CreateEntryArgs,
type EntryFieldArgs,
type GroupEntriesArgs,
type TargetRundownArgs,
type UngroupEntryArgs,
type UpdateEntryArgs,
} from './mcp.service.js';
@@ -88,7 +93,7 @@ export const TOOL_DEFINITIONS = [
{
name: 'ontime_create_entry',
description:
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title only — set colour, note, custom, or targetDuration with ontime_update_entry after creation.',
'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: {
@@ -105,6 +110,7 @@ export const TOOL_DEFINITIONS = [
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,
@@ -148,7 +154,7 @@ export const TOOL_DEFINITIONS = [
{
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 to place an entry inside a group.',
'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'],
@@ -165,10 +171,47 @@ export const TOOL_DEFINITIONS = [
},
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. 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 entry, subsequent entries chain from the previous. 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.',
'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'],
@@ -189,6 +232,13 @@ export const TOOL_DEFINITIONS = [
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,
},
},
@@ -214,6 +264,7 @@ export const TOOL_DEFINITIONS = [
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,
},
},
@@ -461,9 +512,17 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
);
},
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: CreateEntryArgs[]; after?: EntryId }),
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
);
},