mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 09:53:48 +00:00
378 lines
14 KiB
TypeScript
378 lines
14 KiB
TypeScript
import {
|
|
EntryId,
|
|
EventPostPayload,
|
|
InsertOptions,
|
|
OntimeDelay,
|
|
OntimeEntry,
|
|
OntimeEvent,
|
|
OntimeGroup,
|
|
OntimeMilestone,
|
|
PatchWithId,
|
|
ProjectRundowns,
|
|
Rundown,
|
|
SupportedEntry,
|
|
} from 'ontime-types';
|
|
import { checkRegex, customFieldLabelToKey } from 'ontime-utils';
|
|
|
|
import { getCurrentRundown, getCurrentRundownId, getProjectCustomFields } from '../api-data/rundown/rundown.dao.js';
|
|
import {
|
|
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.';
|
|
|
|
// keys are case-sensitive; a near-miss on casing is the most common mistake
|
|
const suggestions = [...unknownKeys]
|
|
.map((key) => {
|
|
const match = available.find((existing) => existing.toLowerCase() === key.toLowerCase());
|
|
return match ? `"${key}" → "${match}"` : null;
|
|
})
|
|
.filter(Boolean);
|
|
const caseHint =
|
|
suggestions.length > 0 ? ` Keys are case-sensitive — did you mean: ${suggestions.join(', ')}?` : '';
|
|
|
|
throw new Error(
|
|
`Unknown custom field key(s): ${missing}. ${hint}${caseHint} ` +
|
|
`Call ontime_create_custom_field with { label, type, colour } to create a missing field — ` +
|
|
`the key is auto-derived from the label (spaces → underscores, e.g. label "Mix Output" → key "Mix_Output").`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** 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 label = args.label?.trim();
|
|
// same constraint the HTTP route enforces in customFields.validation.ts
|
|
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
|
|
throw new Error('Invalid label: use letters, numbers and spaces only, e.g. "Camera Angle".');
|
|
}
|
|
|
|
const key = customFieldLabelToKey(label);
|
|
const existingFields = getProjectCustomFields();
|
|
const clash = Object.keys(existingFields).find((existing) => existing.toLowerCase() === key.toLowerCase());
|
|
if (clash) {
|
|
throw new Error(
|
|
`A custom field with key "${clash}" (label "${existingFields[clash].label}") already exists. ` +
|
|
`Reuse it in entry.custom["${clash}"] instead of creating a duplicate, or pick a clearly different label.`,
|
|
);
|
|
}
|
|
|
|
const updated = await createCustomField({ label, type: args.type, colour: args.colour });
|
|
return { key, customFields: updated };
|
|
}
|
|
|
|
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
|
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 };
|
|
}
|