mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 18:03:47 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 972a246bb6 |
@@ -1,5 +0,0 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*", "zod"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
.kbd {
|
||||
display: inline-block;
|
||||
min-width: 1.5rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 2px;
|
||||
background-color: $gray-1100;
|
||||
color: $ui-white;
|
||||
box-shadow: 0 0 3px rgba(0, 0, 0, 0.4);
|
||||
font-family: monospace;
|
||||
font-size: calc(1rem - 4px);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import style from './Kbd.module.scss';
|
||||
|
||||
export default function Kbd({ children }: PropsWithChildren) {
|
||||
return <kbd className={style.kbd}>{children}</kbd>;
|
||||
}
|
||||
@@ -38,6 +38,8 @@ $progress-bar-br: 3px;
|
||||
.multiprogress-bar__indicator-bar {
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
|
||||
.multiprogress-bar--ignore-css-override & {
|
||||
background-color: $ui-black;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
@@ -35,7 +34,7 @@ export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = 100 - useAnimatedProgress(now, complete);
|
||||
const percentRemaining = 100 - getProgress(now, complete);
|
||||
const dangerWidth = danger ? 100 - getProgress(danger, complete) : 0;
|
||||
const warningWidth = warning ? 100 - dangerWidth - getProgress(warning, complete) : 0;
|
||||
const isOvertime = now !== null && now < 0;
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from '../../../stores/savedViewParams';
|
||||
import { handleLinks } from '../../../utils/linkUtils';
|
||||
import IconButton from '../../buttons/IconButton';
|
||||
import Tooltip from '../../tooltip/Tooltip';
|
||||
import NavigationMenuItem from '../navigation-menu-item/NavigationMenuItem';
|
||||
|
||||
import style from './ClientLink.module.scss';
|
||||
@@ -106,20 +105,16 @@ function BrowserNavigationItem({ current, to, postAction, children }: PropsWithC
|
||||
{isCustomised && (
|
||||
<span className={style.trailing}>
|
||||
<span className={style.indicator} aria-hidden data-testid='client-link__saved-indicator' />
|
||||
<Tooltip
|
||||
text='Reset to default'
|
||||
render={
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Reset to default'
|
||||
onClick={clearViewSettings}
|
||||
/>
|
||||
}
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
className={style.clear}
|
||||
aria-label='Clear saved view settings'
|
||||
title='Clear saved view settings'
|
||||
onClick={clearViewSettings}
|
||||
>
|
||||
<IoCloseOutline />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</span>
|
||||
)}
|
||||
</NavigationMenuItem>
|
||||
|
||||
@@ -14,4 +14,6 @@ $progress-bar-br: 3px;
|
||||
.progress-bar__indicator {
|
||||
height: $progress-bar-size;
|
||||
background-color: var(--timer-progress-override, $accent-color);
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { useAnimatedProgress } from '../../hooks/useAnimatedProgress';
|
||||
import { getProgress } from '../../utils/getProgress';
|
||||
|
||||
import './ProgressBar.scss';
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ProgressBarProps {
|
||||
|
||||
export default function ProgressBar(props: ProgressBarProps) {
|
||||
const { current, duration, className } = props;
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
const progress = getProgress(current, duration);
|
||||
|
||||
return (
|
||||
<div className={`progress-bar__bg ${className}`}>
|
||||
|
||||
@@ -4,60 +4,40 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
.title-card__title,
|
||||
.title-card__placeholder {
|
||||
font-weight: 600;
|
||||
font-size: $title-font-size;
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
border-left: 1vw solid;
|
||||
.title-card__title {
|
||||
color: var(--color-override, $viewer-color);
|
||||
padding-right: 1em;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__title:empty::before {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
.title-card__placeholder {
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.title-card__title {
|
||||
font-weight: 600;
|
||||
line-height: 1.4em;
|
||||
padding-right: 1em;
|
||||
color: var(--color-override, $viewer-color);
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
|
||||
.title-card__secondary {
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
line-height: 1.2em;
|
||||
}
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
text-transform: uppercase;
|
||||
|
||||
&.md {
|
||||
.title-card__title {
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
&.lg {
|
||||
.title-card__title {
|
||||
font-size: $large-font-size;
|
||||
}
|
||||
.title-card__secondary {
|
||||
font-size: $title-font-size;
|
||||
}
|
||||
.schedule__ {
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.title-card__label {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 0.5rem;
|
||||
font-size: $timer-label-size;
|
||||
color: var(--secondary-color-override, $viewer-secondary-color);
|
||||
text-transform: uppercase;
|
||||
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
&--accent {
|
||||
color: var(--accent-color-override, $accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,33 @@
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
import { ForwardedRef, forwardRef } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../translation/TranslationProvider';
|
||||
import { ExtendedEntry } from '../../utils/rundownMetadata';
|
||||
import { cx, enDash } from '../../utils/styleUtils';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import './TitleCard.scss';
|
||||
|
||||
type TitleCardMainProps = {
|
||||
interface TitleCardProps {
|
||||
title?: string;
|
||||
label?: 'now' | 'next';
|
||||
secondary?: string;
|
||||
className?: string;
|
||||
colour?: string;
|
||||
textAlign?: 'left' | 'right' | 'center';
|
||||
size?: 'md' | 'lg';
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type TitleCardExpectedProps = TitleCardMainProps & {
|
||||
event: ExtendedEntry<OntimeEvent>;
|
||||
expectedStart: number;
|
||||
showExpected: boolean;
|
||||
};
|
||||
|
||||
type TitleCardNoExpectedProps = TitleCardMainProps & {
|
||||
event?: undefined;
|
||||
expectedStart?: undefined;
|
||||
showExpected?: false;
|
||||
};
|
||||
|
||||
type TitleCardProps = TitleCardExpectedProps | TitleCardNoExpectedProps;
|
||||
|
||||
export default function TitleCard({
|
||||
label,
|
||||
title,
|
||||
secondary,
|
||||
className = '',
|
||||
colour = 'transparent',
|
||||
textAlign = 'left',
|
||||
size = 'md',
|
||||
placeholder = enDash,
|
||||
}: TitleCardProps) {
|
||||
'use memo';
|
||||
}
|
||||
|
||||
const TitleCard = forwardRef((props: TitleCardProps, ref: ForwardedRef<HTMLDivElement>) => {
|
||||
const { label, title, secondary, className = '' } = props;
|
||||
const { getLocalizedString } = useTranslation();
|
||||
|
||||
const accent = label === 'now';
|
||||
|
||||
return (
|
||||
<div className={cx(['title-card', className, size])} style={{ borderColor: colour }}>
|
||||
<span className='title-card__title' style={{ textAlign }} data-placeholder={placeholder}>
|
||||
{title === '' ? null : title}
|
||||
</span>
|
||||
<div className={cx(['title-card', className])} ref={ref}>
|
||||
<span className='title-card__title'>{title}</span>
|
||||
<span className={cx(['title-card__label', accent && 'title-card__label--accent'])}>
|
||||
{label && getLocalizedString(`common.${label}`)}
|
||||
</span>
|
||||
<div className='title-card__secondary'>{secondary}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
TitleCard.displayName = 'TitleCard';
|
||||
export default TitleCard;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useSearchParams } from 'react-router';
|
||||
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||
import Button from '../buttons/Button';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import Info from '../info/Info';
|
||||
@@ -28,7 +27,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
const clearSavedParams = useSavedViewParams((store) => store.clear);
|
||||
const isSmallScreen = useIsSmallScreen();
|
||||
|
||||
const getPreservedParams = () => getPreservedSearchParams(searchParams, viewOptions);
|
||||
@@ -38,7 +36,6 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
clearSavedParams(target);
|
||||
setSearchParams(getPreservedParams());
|
||||
};
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { MaybeNumber, Playback } from 'ontime-types';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getProgress } from '../utils/getProgress';
|
||||
import { usePlayback } from './useSocket';
|
||||
|
||||
/**
|
||||
* Returns the live completion percentage (0–100) of a countdown, interpolated locally.
|
||||
*/
|
||||
export function useAnimatedProgress(current: MaybeNumber, duration: MaybeNumber): number {
|
||||
const playback = usePlayback();
|
||||
const isRunning = playback === Playback.Play || playback === Playback.Roll;
|
||||
|
||||
const baseline = useRef({ current, at: performance.now() });
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
// there is only something to animate while a running timer is counting down towards 0
|
||||
const shouldAnimate = isRunning && current !== null && current > 0 && duration !== null;
|
||||
|
||||
// re-anchor to the authoritative value whenever the server pushes a new timer update
|
||||
useEffect(() => {
|
||||
baseline.current = { current, at: performance.now() };
|
||||
}, [current, duration, playback]);
|
||||
|
||||
// while counting down, re-render every animation frame so the derived progress stays smooth
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate) {
|
||||
return;
|
||||
}
|
||||
let frame = requestAnimationFrame(function tick() {
|
||||
setTick((value) => value + 1);
|
||||
frame = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [shouldAnimate]);
|
||||
|
||||
// derive from the anchor plus elapsed time at render; frozen to the anchor when not running
|
||||
const anchored = baseline.current.current;
|
||||
const value = isRunning && anchored !== null ? anchored - (performance.now() - baseline.current.at) : anchored;
|
||||
return getProgress(value, duration);
|
||||
}
|
||||
@@ -293,9 +293,8 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
or to change properties of Ontime itself.
|
||||
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
@@ -342,17 +341,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Input {...register(`outputs.${index}.address`)} fluid placeholder='/cue/start' />
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
@@ -382,7 +376,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<div className={style.httpSection}>
|
||||
<label>
|
||||
Target URL
|
||||
<TemplateInput
|
||||
<Input
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
@@ -390,7 +384,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
|
||||
+28
-51
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
|
||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||
|
||||
{ value: 'message-set', label: 'Primary Message' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message' },
|
||||
{ value: 'message-set', label: 'Primary Message: set' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Timer is finished'
|
||||
/>
|
||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Next up: keynote'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'no-change' | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// no-change -> leave the current secondary source untouched
|
||||
if (value === 'no-change') {
|
||||
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
|
||||
options={[
|
||||
{ value: 'no-change', label: 'No change' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
</>
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`)}
|
||||
options={[
|
||||
{ value: null, label: 'Select secondary source' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create a trigger to run an automation'
|
||||
label='Create an automation to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+17
-55
@@ -1,67 +1,29 @@
|
||||
.inputShell {
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.positioner {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.popup {
|
||||
box-sizing: border-box;
|
||||
min-width: var(--anchor-width);
|
||||
max-width: var(--available-width);
|
||||
border: 1px solid $gray-1000;
|
||||
border-radius: $component-border-radius-md;
|
||||
.suggestions {
|
||||
background: $gray-1250;
|
||||
color: $label-gray;
|
||||
}
|
||||
color: $ui-white;
|
||||
|
||||
.list {
|
||||
box-sizing: border-box;
|
||||
max-height: min(20rem, var(--available-height));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: $zindex-floating;
|
||||
padding-block: 0.25rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: $label-gray;
|
||||
|
||||
&[data-highlighted] {
|
||||
li {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
color: $ui-white;
|
||||
background: $blue-700;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedEditor {
|
||||
min-height: min(18rem, 45vh);
|
||||
font-family: monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
margin-right: auto;
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
+43
-232
@@ -1,258 +1,69 @@
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import type { ChangeEvent, ReactNode, Ref } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IoExpandOutline } from 'react-icons/io5';
|
||||
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||
import { forwardRef, useMemo, useState } from 'react';
|
||||
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||
import { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { makeAutoCompleteList } from './templateInput.utils';
|
||||
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
|
||||
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
|
||||
|
||||
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
|
||||
import style from './TemplateInput.module.scss';
|
||||
|
||||
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
value?: string;
|
||||
}
|
||||
interface TemplateInputProps extends InputProps {}
|
||||
|
||||
interface TemplateEditorModalProps {
|
||||
autocompleteList: string[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
|
||||
|
||||
interface TemplateAutocompleteRootProps {
|
||||
autocomplete: TemplateAutocompleteState;
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
|
||||
onChange?.({
|
||||
target: { name, value },
|
||||
currentTarget: { name, value },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
export default function TemplateInput({
|
||||
className,
|
||||
disabled,
|
||||
fluid,
|
||||
height = 'medium',
|
||||
onChange,
|
||||
readOnly,
|
||||
ref,
|
||||
value,
|
||||
variant = 'subtle',
|
||||
...rest
|
||||
}: TemplateInputProps) {
|
||||
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { data } = useCustomFields();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||
|
||||
const autocompleteList = useMemo(() => {
|
||||
return makeAutoCompleteList(data);
|
||||
}, [data]);
|
||||
|
||||
const updateInputValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
setInputValue(nextValue);
|
||||
emitInputChange(rest.name, nextValue, onChange);
|
||||
},
|
||||
[onChange, rest.name],
|
||||
);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
|
||||
const { setCursorForValue } = autocomplete;
|
||||
|
||||
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
|
||||
useEffect(() => {
|
||||
const nextValue = value || '';
|
||||
setInputValue(nextValue);
|
||||
setCursorForValue(nextValue, nextValue.length);
|
||||
}, [setCursorForValue, value]);
|
||||
|
||||
const setInputRef = useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
inputRef.current = element;
|
||||
assignRef(ref, element);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const openExpandedEditor = () => {
|
||||
autocomplete.setShowSuggestions(false);
|
||||
setIsExpanded(true);
|
||||
const updateSuggestions = (value: string) => {
|
||||
const template = selectFromLastTemplate(value);
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
||||
};
|
||||
|
||||
const closeExpandedEditor = () => {
|
||||
setIsExpanded(false);
|
||||
};
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
|
||||
const saveExpandedEditor = (nextValue: string) => {
|
||||
updateInputValue(nextValue);
|
||||
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
|
||||
<div className={cx([style.inputShell, fluid && style.fluid])}>
|
||||
<BaseAutocomplete.Input
|
||||
ref={setInputRef}
|
||||
className={cx([
|
||||
inputStyle.input,
|
||||
inputStyle[variant],
|
||||
inputStyle[height],
|
||||
fluid && inputStyle.fluid,
|
||||
style.input,
|
||||
className,
|
||||
])}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Expand template editor'
|
||||
className={style.expandButton}
|
||||
disabled={disabled || readOnly}
|
||||
onClick={openExpandedEditor}
|
||||
size='small'
|
||||
title='Expand template editor'
|
||||
variant='ghosted-white'
|
||||
>
|
||||
<IoExpandOutline />
|
||||
</IconButton>
|
||||
</div>
|
||||
</TemplateAutocompleteRoot>
|
||||
<TemplateEditorModal
|
||||
autocompleteList={autocompleteList}
|
||||
isOpen={isExpanded}
|
||||
onClose={closeExpandedEditor}
|
||||
onSave={saveExpandedEditor}
|
||||
value={inputValue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Root
|
||||
items={autocomplete.suggestions}
|
||||
autoHighlight
|
||||
highlightItemOnHover
|
||||
mode='none'
|
||||
open={autocomplete.open}
|
||||
value={value}
|
||||
onOpenChange={autocomplete.setShowSuggestions}
|
||||
onValueChange={autocomplete.handleValueChange}
|
||||
>
|
||||
{children}
|
||||
<TemplateSuggestionPopup />
|
||||
</BaseAutocomplete.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
|
||||
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
|
||||
const { setShowSuggestions } = autocomplete;
|
||||
|
||||
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
if (event.target.value.endsWith('{')) {
|
||||
setShowSuggestions(true);
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||
setShowSuggestions(false);
|
||||
} else if (showSuggestions) {
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
}
|
||||
|
||||
setDraftValue(value);
|
||||
setShowSuggestions(false);
|
||||
}, [isOpen, setShowSuggestions, value]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowSuggestions(false);
|
||||
onClose();
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSuggestion = (value: string) => {
|
||||
setInputValue((prev) => {
|
||||
const remaining = matchRemaining(prev as string, value);
|
||||
return prev + remaining;
|
||||
});
|
||||
setShowSuggestions(false);
|
||||
onSave(draftValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Edit template'
|
||||
bodyElements={
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
|
||||
<BaseAutocomplete.Input
|
||||
autoFocus
|
||||
className={style.expandedEditor}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
|
||||
/>
|
||||
</TemplateAutocompleteRoot>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className={style.suggestions}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function TemplateSuggestionPopup() {
|
||||
return (
|
||||
<BaseAutocomplete.Portal>
|
||||
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
|
||||
<BaseAutocomplete.Popup className={style.popup}>
|
||||
<BaseAutocomplete.List className={style.list}>
|
||||
<BaseAutocomplete.Collection>
|
||||
{(suggestion: string) => (
|
||||
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
|
||||
{suggestion}
|
||||
</BaseAutocomplete.Item>
|
||||
)}
|
||||
</BaseAutocomplete.Collection>
|
||||
</BaseAutocomplete.List>
|
||||
</BaseAutocomplete.Popup>
|
||||
</BaseAutocomplete.Positioner>
|
||||
</BaseAutocomplete.Portal>
|
||||
);
|
||||
}
|
||||
export default TemplateInput;
|
||||
|
||||
+1
-52
@@ -1,4 +1,4 @@
|
||||
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
||||
import { matchRemaining } from '../templateInput.utils';
|
||||
|
||||
describe('matchRemaining()', () => {
|
||||
it('should return a partial string needed for autocomplete', () => {
|
||||
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
|
||||
expect(matchRemaining('test', 'test')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectActiveTemplate()', () => {
|
||||
it('returns the last unclosed template fragment', () => {
|
||||
expect(selectActiveTemplate('send {{event')).toBe('{{event');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
|
||||
});
|
||||
|
||||
it('ignores single braces and closed templates', () => {
|
||||
expect(selectActiveTemplate('send {')).toBe('');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
|
||||
});
|
||||
|
||||
it('only considers templates before the cursor', () => {
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
|
||||
});
|
||||
|
||||
it('selects a partial template when the cursor is inside a completed template', () => {
|
||||
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTemplateAtCursor()', () => {
|
||||
it('completes the active template before the cursor', () => {
|
||||
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
|
||||
value: 'send {{timer.current}} after',
|
||||
cursorIndex: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves text before and after the cursor', () => {
|
||||
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
|
||||
value: 'before {{eventNow.title}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a following template when completing between templates', () => {
|
||||
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
|
||||
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
|
||||
cursorIndex: 31,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the whole template when the cursor is inside a completed template', () => {
|
||||
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
|
||||
value: 'before {{timer.duration}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-64
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
|
||||
'{{eventNext.delay}}',
|
||||
];
|
||||
|
||||
const groupStaticPropertiesNow = [
|
||||
'{{groupNow.id}}',
|
||||
'{{groupNow.title}}',
|
||||
'{{groupNow.note}}',
|
||||
'{{groupNow.colour}}',
|
||||
'{{groupNow.timeStart}}',
|
||||
'{{groupNow.timeEnd}}',
|
||||
'{{groupNow.duration}}',
|
||||
];
|
||||
|
||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||
`{{auxtimer${index}.current}}`,
|
||||
`{{auxtimer${index}.duration}}`,
|
||||
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||
...groupStaticPropertiesNow,
|
||||
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
|
||||
...staticAuxProperties(1),
|
||||
...staticAuxProperties(2),
|
||||
...staticAuxProperties(3),
|
||||
];
|
||||
}
|
||||
|
||||
interface TemplateCompletion {
|
||||
cursorIndex: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the partial string b needed to autocomplete string a
|
||||
* @example matchRemaining('te', 'test') -> 'st'
|
||||
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
||||
const textBeforeCursor = text.slice(0, cursorIndex);
|
||||
const start = textBeforeCursor.lastIndexOf('{{');
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
|
||||
if (closeBeforeCursor > start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeAfterStart = text.indexOf('}}', start);
|
||||
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
|
||||
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
|
||||
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
|
||||
|
||||
return {
|
||||
end,
|
||||
start,
|
||||
template: text.slice(start, cursorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the last unclosed starting template before the cursor.
|
||||
* Selects the last starting template in a string
|
||||
*/
|
||||
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
|
||||
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the active template fragment before the cursor with the selected suggestion.
|
||||
*/
|
||||
export function completeTemplateAtCursor(
|
||||
text: string,
|
||||
suggestion: string,
|
||||
cursorIndex = text.length,
|
||||
): TemplateCompletion {
|
||||
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
|
||||
if (!activeTemplateRange) {
|
||||
const value = text + matchRemaining(text, suggestion);
|
||||
return { value, cursorIndex: value.length };
|
||||
export function selectFromLastTemplate(text: string) {
|
||||
const lastBraceIndex = text.lastIndexOf('{{');
|
||||
if (lastBraceIndex !== -1) {
|
||||
return text.slice(lastBraceIndex);
|
||||
}
|
||||
|
||||
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
|
||||
return {
|
||||
value,
|
||||
cursorIndex: activeTemplateRange.start + suggestion.length,
|
||||
};
|
||||
return '';
|
||||
}
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import { useCallback, useMemo, useState, type RefObject } from 'react';
|
||||
|
||||
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
|
||||
|
||||
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function getCursorIndex(element: TemplateElement | null, fallback: string) {
|
||||
return element?.selectionStart ?? fallback.length;
|
||||
}
|
||||
|
||||
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
|
||||
requestAnimationFrame(() => {
|
||||
element?.focus();
|
||||
element?.setSelectionRange(cursorIndex, cursorIndex);
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplateAutocomplete<T extends TemplateElement>(
|
||||
value: string,
|
||||
autocompleteList: string[],
|
||||
elementRef: RefObject<T | null>,
|
||||
onValueChange: (value: string) => void,
|
||||
) {
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
const activeTemplate = selectActiveTemplate(value, cursor);
|
||||
const suggestions = useMemo(() => {
|
||||
if (!activeTemplate) {
|
||||
return [];
|
||||
}
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
|
||||
}, [activeTemplate, autocompleteList]);
|
||||
|
||||
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
|
||||
setCursor(cursorIndex);
|
||||
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
|
||||
}, []);
|
||||
|
||||
const updateCursor = useCallback(() => {
|
||||
const cursorIndex = getCursorIndex(elementRef.current, value);
|
||||
setCursorForValue(value, cursorIndex);
|
||||
}, [elementRef, setCursorForValue, value]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
|
||||
if (eventDetails.reason === 'item-press') {
|
||||
eventDetails.cancel();
|
||||
const completed = completeTemplateAtCursor(value, nextValue, cursor);
|
||||
setCursorForValue(completed.value, completed.cursorIndex);
|
||||
onValueChange(completed.value);
|
||||
focusCursor(elementRef.current, completed.cursorIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
|
||||
setCursorForValue(nextValue, cursorIndex);
|
||||
onValueChange(nextValue);
|
||||
},
|
||||
[cursor, elementRef, onValueChange, setCursorForValue, value],
|
||||
);
|
||||
|
||||
return {
|
||||
handleValueChange,
|
||||
open: showSuggestions && suggestions.length > 0,
|
||||
setCursorForValue,
|
||||
setShowSuggestions,
|
||||
suggestions,
|
||||
updateCursor,
|
||||
};
|
||||
}
|
||||
@@ -180,13 +180,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
<OperatorGroup
|
||||
key={entry.id}
|
||||
title={entry.title}
|
||||
colour={entry.colour}
|
||||
count={entry.entries.length}
|
||||
duration={entry.duration}
|
||||
/>
|
||||
<OperatorGroup key={entry.id} title={entry.title} />
|
||||
{entry.entries.map((nestedEntryId) => {
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!isOntimeEvent(nestedEntry)) {
|
||||
@@ -223,7 +217,6 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
||||
isLinkedToLoaded={isLinkedToLoaded}
|
||||
isSelected={isLoaded}
|
||||
isPast={isPast}
|
||||
groupColour={entry.colour}
|
||||
selectedRef={isLoaded ? selectedRef : undefined}
|
||||
showStart={showStart}
|
||||
subscribed={subscribedData}
|
||||
|
||||
@@ -22,39 +22,17 @@
|
||||
background-color: $gray-1250;
|
||||
}
|
||||
|
||||
&.grouped {
|
||||
position: relative;
|
||||
padding-left: 0.35rem;
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%), transparent 8rem),
|
||||
$viewer-card-bg-color;
|
||||
}
|
||||
|
||||
&.running {
|
||||
border-top: 1px solid $gray-1300;
|
||||
background-color: var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
&.grouped.running {
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, transparent 82%, var(--group-colour, $gray-500) 18%), transparent 8rem),
|
||||
var(--operator-running-bg-override, $active-green);
|
||||
}
|
||||
|
||||
&.past {
|
||||
border-top: 1px solid transparent;
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.groupRail {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 0.35rem;
|
||||
background-color: var(--group-colour, $gray-500);
|
||||
}
|
||||
|
||||
.binder {
|
||||
grid-area: binder;
|
||||
color: $section-white;
|
||||
@@ -96,9 +74,6 @@
|
||||
.plannedStart,
|
||||
.timeUntil,
|
||||
.runningTime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: $component-border-radius-md;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: 1;
|
||||
@@ -120,21 +95,6 @@
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.live {
|
||||
color: $ui-black;
|
||||
background-color: $ui-white;
|
||||
}
|
||||
|
||||
.due {
|
||||
color: $ui-black;
|
||||
background-color: $orange-500;
|
||||
}
|
||||
|
||||
.done {
|
||||
color: $white-60;
|
||||
background-color: $white-7;
|
||||
}
|
||||
|
||||
.runningTime {
|
||||
grid-area: running;
|
||||
font-size: 1.25rem;
|
||||
|
||||
@@ -25,7 +25,6 @@ interface OperatorEventProps {
|
||||
isLinkedToLoaded: boolean;
|
||||
isSelected: boolean;
|
||||
isPast: boolean;
|
||||
groupColour?: string;
|
||||
selectedRef?: RefObject<HTMLDivElement | null>;
|
||||
showStart: boolean;
|
||||
subscribed: Subscribed;
|
||||
@@ -47,7 +46,6 @@ function OperatorEvent({
|
||||
isLinkedToLoaded,
|
||||
isSelected,
|
||||
isPast,
|
||||
groupColour,
|
||||
selectedRef,
|
||||
showStart,
|
||||
subscribed,
|
||||
@@ -70,12 +68,7 @@ function OperatorEvent({
|
||||
const mouseHandlers = useLongPress(handleLongPress);
|
||||
const cueColours = colour && getAccessibleColour(colour);
|
||||
|
||||
const operatorClasses = cx([
|
||||
style.event,
|
||||
groupColour && style.grouped,
|
||||
isSelected && style.running,
|
||||
isPast && style.past,
|
||||
]);
|
||||
const operatorClasses = cx([style.event, isSelected && style.running, isPast && style.past]);
|
||||
|
||||
const hasFields = subscribed.some((field) => field.value);
|
||||
const columnCount = subscribed.length ? Math.min(subscribed.length, 4) : 0;
|
||||
@@ -92,10 +85,8 @@ function OperatorEvent({
|
||||
data-testid={cue}
|
||||
ref={selectedRef}
|
||||
onContextMenu={handleLongPress}
|
||||
style={groupColour ? ({ '--group-colour': groupColour } as CSSProperties) : undefined}
|
||||
{...mouseHandlers}
|
||||
>
|
||||
{groupColour && <div className={style.groupRail} />}
|
||||
<div className={style.binder} style={{ ...cueColours }}>
|
||||
<span className={style.cue}>{cue}</span>
|
||||
</div>
|
||||
@@ -162,11 +153,11 @@ function OperatorEventSchedule({
|
||||
isLinkedToLoaded,
|
||||
}: OperatorEventScheduleProps) {
|
||||
if (isPast) {
|
||||
return <span className={cx([style.timeUntil, style.done])}>DONE</span>;
|
||||
return <span className={style.timeUntil}>DONE</span>;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return <span className={cx([style.timeUntil, style.live])}>LIVE</span>;
|
||||
return <span className={style.timeUntil}>LIVE</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -195,7 +186,7 @@ function TimeUntil({ timeStart, delay, dayOffset, totalGap, isLinkedToLoaded }:
|
||||
const timeUntilString = isDue ? 'DUE' : `${formatDuration(Math.abs(timeUntil), timeUntil > 2 * MILLIS_PER_MINUTE)}`;
|
||||
|
||||
return (
|
||||
<span className={cx([style.timeUntil, isDue && style.due])} data-testid='time-until'>
|
||||
<span className={style.timeUntil} data-testid='time-until'>
|
||||
{timeUntilString}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
.group {
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: $gray-1350;
|
||||
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
// tablet
|
||||
@media (min-width: $min-tablet) {
|
||||
.group {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
import { CSSProperties, memo } from 'react';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
import { memo } from 'react';
|
||||
|
||||
import style from './OperatorGroup.module.scss';
|
||||
|
||||
interface OperatorGroup {
|
||||
title: string;
|
||||
colour: string;
|
||||
count: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default memo(OperatorGroup);
|
||||
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||
const groupColour = colour || '#929292';
|
||||
const groupColours = getAccessibleColour(groupColour);
|
||||
|
||||
return (
|
||||
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||
<span className={style.title}>{title}</span>
|
||||
<span className={style.meta}>
|
||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
function OperatorGroup({ title }: OperatorGroup) {
|
||||
return <div className={style.group}>{title}</div>;
|
||||
}
|
||||
|
||||
@@ -3,94 +3,58 @@
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.shortcutSection {
|
||||
flex: 1;
|
||||
width: min(100%, 48rem);
|
||||
margin-top: clamp(1.5rem, 8vh, 5rem);
|
||||
margin-top: 15vh;
|
||||
margin-inline: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
font-size: calc(1rem - 3px);
|
||||
border-collapse: separate;
|
||||
border-spacing: 4rem 0;
|
||||
|
||||
.shortcutGroup {
|
||||
h3 {
|
||||
margin: 0 0 0.375rem;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 3px);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
tr {
|
||||
white-space: nowrap;
|
||||
td:nth-child(odd) {
|
||||
text-align: left;
|
||||
}
|
||||
td:nth-child(even) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shortcutList {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
min-height: 1.625rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 1fr) minmax(0, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.shortcutLabel {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyCombo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.25rem 0;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: $gray-500;
|
||||
font-size: calc(1rem - 5px);
|
||||
.spacer {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
font-size: calc(1rem - 2px);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background-color: $gray-1200;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.entryEditor {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.shortcutSection {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shortcutRow {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortcutKeys {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
border-radius: 2px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PropsWithChildren, memo } from 'react';
|
||||
|
||||
import * as Editor from '../../../common/components/editor-utils/EditorUtils';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
|
||||
import style from './EventEditorEmpty.module.scss';
|
||||
@@ -13,124 +12,216 @@ function EventEditorEmpty() {
|
||||
<div className={style.entryEditor} data-testid='editor-container'>
|
||||
<div className={style.shortcutSection}>
|
||||
<Editor.Title className={style.prompt}>Rundown shortcuts</Editor.Title>
|
||||
<div className={style.shortcuts}>
|
||||
<ShortcutGroup title='Search'>
|
||||
<Shortcut label='Find in rundown'>
|
||||
<Combo keys={[deviceMod, 'F']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Open settings'>
|
||||
<Combo keys={[deviceMod, ',']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Navigation'>
|
||||
<Shortcut label='Select entry'>
|
||||
<Combo keys={[deviceAlt, '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Select group'>
|
||||
<Combo keys={[deviceAlt, 'Shift', '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to top / bottom'>
|
||||
<Combo keys={['Home']} />
|
||||
<Separator />
|
||||
<Combo keys={['End']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Page up / down'>
|
||||
<Combo keys={['PgUp']} />
|
||||
<Separator />
|
||||
<Combo keys={['PgDn']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Deselect entry'>
|
||||
<Combo keys={['Esc']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Editing'>
|
||||
<Shortcut label='Reorder selected entry'>
|
||||
<Combo keys={[deviceAlt, deviceMod, '↑']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, deviceMod, '↓']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Copy selected entry'>
|
||||
<Combo keys={[deviceMod, 'C']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Cut selected entry'>
|
||||
<Combo keys={[deviceMod, 'X']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Paste below'>
|
||||
<Combo keys={[deviceMod, 'V']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Paste above'>
|
||||
<Combo keys={[deviceMod, 'Shift', 'V']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Clone selected entry'>
|
||||
<Combo keys={[deviceMod, 'D']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Delete selected entry'>
|
||||
<Combo keys={[deviceAlt, 'Backspace']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
|
||||
<ShortcutGroup title='Insert'>
|
||||
<Shortcut label='Add event below / above'>
|
||||
<Combo keys={[deviceAlt, 'E']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'E']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add group below / above'>
|
||||
<Combo keys={[deviceAlt, 'G']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'G']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add milestone below / above'>
|
||||
<Combo keys={[deviceAlt, 'M']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'M']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Add delay below / above'>
|
||||
<Combo keys={[deviceAlt, 'D']} />
|
||||
<Separator />
|
||||
<Combo keys={[deviceAlt, 'Shift', 'D']} />
|
||||
</Shortcut>
|
||||
</ShortcutGroup>
|
||||
</div>
|
||||
<table className={style.shortcuts}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Find in rundown</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>F</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Open Settings</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>,</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Select entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Select group</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Jump to top / bottom</td>
|
||||
<td>
|
||||
<Kbd>Home</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>End</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Page up / down</td>
|
||||
<td>
|
||||
<Kbd>PgUp</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>PgDn</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deselect entry</td>
|
||||
<td>
|
||||
<Kbd>Esc</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Reorder selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Copy selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>C</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cut selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>X</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste above</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Paste below</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Clone selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Backspace</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Add event below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add event above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add group below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>G</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add group above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>G</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add milestone below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>M</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add milestone above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>M</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutGroup({ title, children }: PropsWithChildren<{ title: string }>) {
|
||||
return (
|
||||
<section className={style.shortcutGroup}>
|
||||
<h3>{title}</h3>
|
||||
<div className={style.shortcutList}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
function AuxKey({ children }: PropsWithChildren) {
|
||||
return <span className={style.divider}>{children}</span>;
|
||||
}
|
||||
|
||||
function Shortcut({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||
return (
|
||||
<div className={style.shortcutRow}>
|
||||
<span className={style.shortcutLabel}>{label}</span>
|
||||
<span className={style.shortcutKeys}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Combo({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className={style.keyCombo}>
|
||||
{keys.map((key) => (
|
||||
<Kbd key={key}>{key}</Kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <span className={style.separator}>/</span>;
|
||||
function Kbd({ children }: PropsWithChildren) {
|
||||
return <span className={style.kbd}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
@@ -156,7 +156,7 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
onClick: () => {
|
||||
clearSelectedEvents();
|
||||
deleteEntry(Array.from(selectedEvents));
|
||||
@@ -202,7 +202,7 @@ export default function RundownEvent({
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
onClick: () => {
|
||||
deleteEntry([eventId]);
|
||||
unselect(eventId);
|
||||
|
||||
+2
@@ -3,5 +3,7 @@
|
||||
width: 0;
|
||||
border-radius: 1px 0 0 1px;
|
||||
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
background-color: $gray-200;
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { useAnimatedProgress } from '../../../../common/hooks/useAnimatedProgress';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../../../common/utils/getProgress';
|
||||
|
||||
import style from './RundownEventProgressBar.module.scss';
|
||||
|
||||
export default function RundownEventProgressBar() {
|
||||
const timer = useTimer();
|
||||
|
||||
const progress = useAnimatedProgress(timer.current, timer.duration);
|
||||
const progress = getProgress(timer.current, timer.duration);
|
||||
|
||||
return <div className={style.progressBar} style={{ width: `${progress}%` }} />;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import Tag from '../../../common/components/tag/Tag';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
@@ -66,7 +66,7 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
onClick: () => deleteEntry([data.id]),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import useReactiveTextInput from '../../../common/components/input/text-input/us
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt } from '../../../common/utils/deviceUtils';
|
||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function RundownMilestone({ colour, cue, entryId, hasCursor, titl
|
||||
type: 'item',
|
||||
label: 'Delete',
|
||||
icon: IoTrash,
|
||||
shortcut: `${deviceAlt}+Backspace`,
|
||||
shortcut: `${deviceMod}+Del`,
|
||||
onClick: () => deleteEntry([entryId]),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -12,7 +12,6 @@ $viewer-opacity-disabled: 0.6;
|
||||
$timer-label-size: clamp(12px, 1.25vw, 20px);
|
||||
$base-font-size: clamp(15px, 1.5vw, 28px);
|
||||
$title-font-size: clamp(18px, 2.25vw, 42px);
|
||||
$large-font-size: clamp(40px, 4.5vw, 80px);
|
||||
$timer-value-size: clamp(24px, 2.5vw, 48px);
|
||||
$header-font-size: clamp(24px, 2.5vw, 48px);
|
||||
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
color: var(--label-color-override, $viewer-label-color);
|
||||
}
|
||||
|
||||
.title-card {
|
||||
// overwrite the title-card bg color so they don't stack as it is transparent
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* =================== HEADER + EXTRAS ===================*/
|
||||
|
||||
.project-header {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, TimerPhase } from 'ontime-types';
|
||||
|
||||
import { enDash } from '../../common/utils/styleUtils';
|
||||
import { getPropertyValue } from '../common/viewUtils';
|
||||
|
||||
/**
|
||||
@@ -45,9 +46,9 @@ export function getCardData(
|
||||
}
|
||||
|
||||
// if we are loaded, we show the upcoming event as next
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title');
|
||||
const nowMain = getPropertyValue(eventNow, mainSource ?? 'title') || enDash;
|
||||
const nowSecondary = getPropertyValue(eventNow, secondarySource);
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title');
|
||||
const nextMain = getPropertyValue(eventNext, mainSource ?? 'title') || enDash;
|
||||
const nextSecondary = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
return {
|
||||
|
||||
@@ -54,51 +54,17 @@
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.em {
|
||||
color: $ui-white;
|
||||
margin-inline: 0.25rem;
|
||||
}
|
||||
|
||||
.hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.hintItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.footer {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filterHint {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { SupportedEntry } from 'ontime-types';
|
||||
import { KeyboardEvent, useState } from 'react';
|
||||
|
||||
import Input from '../../../common/components/input/input/Input';
|
||||
import Kbd from '../../../common/components/kbd/Kbd';
|
||||
import Modal from '../../../common/components/modal/Modal';
|
||||
import useFinder from './useFinder';
|
||||
|
||||
@@ -97,25 +96,8 @@ export default function Finder({ isOpen, onClose }: FinderProps) {
|
||||
}
|
||||
footerElements={
|
||||
<div className={style.footer}>
|
||||
<div className={style.hints}>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>Enter</Kbd>
|
||||
Go
|
||||
</span>
|
||||
<span className={style.hintItem}>
|
||||
<Kbd>Esc</Kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
<div className={style.filterHint}>
|
||||
Filter by <span className={style.em}>cue</span>, <span className={style.em}>index</span>, or
|
||||
<span className={style.em}>title</span>
|
||||
</div>
|
||||
Use the keywords <span className={style.em}>cue</span>, <span className={style.em}>index</span> or
|
||||
<span className={style.em}>title</span> to filter search.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Day } from 'ontime-types';
|
||||
import { CSSProperties, RefObject } from 'react';
|
||||
|
||||
import { useAnimatedProgress } from '../../common/hooks/useAnimatedProgress';
|
||||
import { useExpectedStartData, useTimer } from '../../common/hooks/useSocket';
|
||||
import { getProgress } from '../../common/utils/getProgress';
|
||||
import { alpha, cx } from '../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime, getExpectedTimesFromExtendedEvent } from '../../common/utils/time';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -169,7 +169,7 @@ function TimelineEntryStatus({
|
||||
/** Generates a block level progress bar */
|
||||
function ActiveBlock() {
|
||||
const { current, duration } = useTimer();
|
||||
const progress = useAnimatedProgress(current, duration);
|
||||
const progress = getProgress(current, duration);
|
||||
return (
|
||||
<div data-status='live' className={style.timelineBlock} style={{ '--progress': `${progress}%` } as CSSProperties} />
|
||||
);
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
/* =================== TITLES ===================*/
|
||||
|
||||
.event {
|
||||
background-color: var(--card-background-color-override, $viewer-card-bg-color);
|
||||
padding: $view-card-padding;
|
||||
border-radius: $element-border-radius;
|
||||
|
||||
&.now {
|
||||
grid-area: now;
|
||||
}
|
||||
|
||||
@@ -206,24 +206,8 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings,
|
||||
|
||||
{!hideCards && (
|
||||
<>
|
||||
{showNow && (
|
||||
<TitleCard
|
||||
className='event now'
|
||||
label='now'
|
||||
title={nowMain}
|
||||
secondary={nowSecondary}
|
||||
colour={eventNow?.colour}
|
||||
/>
|
||||
)}
|
||||
{showNext && (
|
||||
<TitleCard
|
||||
className='event next'
|
||||
label='next'
|
||||
title={nextMain}
|
||||
secondary={nextSecondary}
|
||||
colour={eventNext?.colour}
|
||||
/>
|
||||
)}
|
||||
{showNow && <TitleCard className='event now' label='now' title={nowMain} secondary={nowSecondary} />}
|
||||
{showNext && <TitleCard className='event next' label='next' title={nextMain} secondary={nextSecondary} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"$schema": "../../node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"]
|
||||
"plugins": ["unicorn", "typescript", "oxc", "vitest", "node", "promise"],
|
||||
"rules": {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"osc-min": "2.1.2",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
|
||||
@@ -163,70 +163,5 @@ describe('parseOutput', () => {
|
||||
secondarySource: 'secondary',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses message-secondary with a text value', () => {
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: 'hello',
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: undefined,
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: 'hello',
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'secondary',
|
||||
text: 'hello',
|
||||
});
|
||||
// an empty text is treated as no change
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: '',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'secondary',
|
||||
text: undefined,
|
||||
});
|
||||
// text can be set while clearing the secondary source
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: null,
|
||||
text: 'hello',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: null,
|
||||
text: 'hello',
|
||||
});
|
||||
expect(() =>
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 123,
|
||||
}),
|
||||
).toThrow('Unexpected payload type:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { runtimeStorePlaceholder } from 'ontime-types';
|
||||
|
||||
import * as messageService from '../../../services/message-service/message.service.js';
|
||||
import { toOntimeAction } from '../clients/ontime.client.js';
|
||||
|
||||
vi.mock('../../../services/message-service/message.service.js', () => ({
|
||||
patch: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('toOntimeAction()', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('parses templates in primary message text', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: 'Current: {{timer.current}}',
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
timer: {
|
||||
...runtimeStorePlaceholder.timer,
|
||||
current: 42,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
timer: {
|
||||
text: 'Current: 42',
|
||||
visible: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses templates in secondary message text', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'secondary',
|
||||
text: 'Next: {{eventNext.title}}',
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
eventNext: {
|
||||
id: 'next-event',
|
||||
type: 'event',
|
||||
cue: '2',
|
||||
title: 'Keynote',
|
||||
note: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
timerType: 'count-down',
|
||||
colour: '',
|
||||
delay: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
endAction: 'none',
|
||||
revision: 0,
|
||||
custom: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
timer: {
|
||||
secondarySource: 'secondary',
|
||||
},
|
||||
secondary: 'Next: Keynote',
|
||||
});
|
||||
});
|
||||
|
||||
it('can set secondary message text without changing the secondary source', () => {
|
||||
toOntimeAction(
|
||||
{
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text: 'Next: {{eventNext.title}}',
|
||||
},
|
||||
{
|
||||
...runtimeStorePlaceholder,
|
||||
eventNext: {
|
||||
id: 'next-event',
|
||||
type: 'event',
|
||||
cue: '2',
|
||||
title: 'Keynote',
|
||||
note: '',
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
timerType: 'count-down',
|
||||
colour: '',
|
||||
delay: 0,
|
||||
isPublic: true,
|
||||
skip: false,
|
||||
endAction: 'none',
|
||||
revision: 0,
|
||||
custom: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(messageService.patch).toHaveBeenCalledWith({
|
||||
secondary: 'Next: Keynote',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ function send(output: AutomationOutput[], store: RuntimeStore) {
|
||||
} else if (isHTTPOutput(payload)) {
|
||||
emitHTTP(payload, store);
|
||||
} else if (isOntimeAction(payload)) {
|
||||
toOntimeAction(payload, store);
|
||||
toOntimeAction(payload);
|
||||
} else {
|
||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||
}
|
||||
|
||||
@@ -225,28 +225,13 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
}
|
||||
|
||||
if (maybeOntimeAction.action === 'message-secondary') {
|
||||
// the secondary text is optional, an empty string is treated as no change
|
||||
let text: string | undefined = undefined;
|
||||
if ('text' in maybeOntimeAction) {
|
||||
assert.isString(maybeOntimeAction.text);
|
||||
text = indeterminateText(maybeOntimeAction.text);
|
||||
}
|
||||
|
||||
if (!('secondarySource' in maybeOntimeAction) || maybeOntimeAction.secondarySource === undefined) {
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
||||
// null is used to clear the secondary source
|
||||
if (maybeOntimeAction.secondarySource === null) {
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: null,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -255,7 +240,6 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { LogOrigin, OntimeAction, RuntimeStore } from 'ontime-types';
|
||||
import { LogOrigin, OntimeAction } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
import { DeepReadonly } from 'ts-essentials';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
|
||||
import * as messageService from '../../../services/message-service/message.service.js';
|
||||
import { runtimeService } from '../../../services/runtime-service/runtime.service.js';
|
||||
import { parseTemplateNested } from '../automation.utils.js';
|
||||
|
||||
export function toOntimeAction(action: OntimeAction, store: DeepReadonly<RuntimeStore>) {
|
||||
export function toOntimeAction(action: OntimeAction) {
|
||||
const actionType = action.action;
|
||||
switch (actionType) {
|
||||
// Aux timer actions
|
||||
@@ -57,25 +55,18 @@ export function toOntimeAction(action: OntimeAction, store: DeepReadonly<Runtime
|
||||
case 'message-set': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
text: action.text ? parseTemplateNested(action.text, store) : action.text,
|
||||
text: action.text,
|
||||
visible: action.visible,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'message-secondary': {
|
||||
const secondary = action.text ? parseTemplateNested(action.text, store) : action.text;
|
||||
const patch =
|
||||
action.secondarySource === undefined
|
||||
? { secondary }
|
||||
: {
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
secondary,
|
||||
};
|
||||
|
||||
messageService.patch(patch);
|
||||
messageService.patch({
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||
import { validateGenerateUrl } from '../session.validation.js';
|
||||
|
||||
describe('validateGenerateUrl', () => {
|
||||
it('accepts a valid payload and normalises req.body', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: true,
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body).toMatchObject({ baseUrl: 'https://ontime.example', path: '/timer' });
|
||||
});
|
||||
|
||||
it('accepts an optional preset field', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: true,
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
preset: 'my-preset',
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body.preset).toBe('my-preset');
|
||||
});
|
||||
|
||||
it('rejects a missing required field with a 422', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||
body: { path: '/timer', authenticate: true, lockConfig: false, lockNav: false },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects a wrong-typed field', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateGenerateUrl, {
|
||||
body: {
|
||||
baseUrl: 'https://ontime.example',
|
||||
path: '/timer',
|
||||
authenticate: 'yes', // should be boolean
|
||||
lockConfig: false,
|
||||
lockNav: false,
|
||||
},
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { ErrorResponse, GetInfo, GetUrl, SessionStats } from 'ontime-types'
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import * as sessionService from './session.service.js';
|
||||
import type { GenerateUrlInput } from './session.validation.js';
|
||||
import { validateGenerateUrl } from './session.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -28,17 +29,21 @@ router.get('/info', (_req: Request, res: Response<GetInfo | ErrorResponse>) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/url', validateGenerateUrl, (req: Request, res: Response<GetUrl | ErrorResponse>) => {
|
||||
try {
|
||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||
authenticate: req.body.authenticate,
|
||||
lockConfig: req.body.lockConfig,
|
||||
lockNav: req.body.lockNav,
|
||||
preset: req.body.preset,
|
||||
});
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
'/url',
|
||||
validateGenerateUrl,
|
||||
(req: Request<unknown, GetUrl | ErrorResponse, GenerateUrlInput>, res: Response<GetUrl | ErrorResponse>) => {
|
||||
try {
|
||||
const url = sessionService.generateShareUrl(req.body.baseUrl, req.body.path, {
|
||||
authenticate: req.body.authenticate,
|
||||
lockConfig: req.body.lockConfig,
|
||||
lockNav: req.body.lockNav,
|
||||
preset: req.body.preset,
|
||||
});
|
||||
res.status(200).send({ url: url.toString() });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { body } from 'express-validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { validateBody } from '../validation-utils/validate.js';
|
||||
|
||||
export const validateGenerateUrl = [
|
||||
body('baseUrl').isString().trim().notEmpty(),
|
||||
body('path').isString().trim().notEmpty(),
|
||||
|
||||
body('authenticate').isBoolean(),
|
||||
body('lockConfig').isBoolean(),
|
||||
body('lockNav').isBoolean(),
|
||||
body('preset').optional().isString().trim().notEmpty(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
const generateUrlSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1),
|
||||
path: z.string().trim().min(1),
|
||||
authenticate: z.boolean(),
|
||||
lockConfig: z.boolean(),
|
||||
lockNav: z.boolean(),
|
||||
preset: z.string().trim().min(1).optional(),
|
||||
});
|
||||
export type GenerateUrlInput = z.infer<typeof generateUrlSchema>;
|
||||
export const validateGenerateUrl = validateBody(generateUrlSchema);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { runMiddleware } from '../../validation-utils/__tests__/testMiddleware.js';
|
||||
import { validateNewPreset, validatePresetParam } from '../urlPresets.validation.js';
|
||||
|
||||
const validPreset = {
|
||||
enabled: true,
|
||||
alias: 'my-preset',
|
||||
target: OntimeView.Cuesheet,
|
||||
search: '',
|
||||
displayInNav: true,
|
||||
};
|
||||
|
||||
describe('validateNewPreset', () => {
|
||||
it('accepts a valid preset', () => {
|
||||
const { nextCalled } = runMiddleware(validateNewPreset, { body: validPreset });
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts optional cuesheet options', () => {
|
||||
const { nextCalled, req } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, options: { read: 'a', write: 'b' } },
|
||||
});
|
||||
expect(nextCalled).toBe(true);
|
||||
expect(req.body.options).toEqual({ read: 'a', write: 'b' });
|
||||
});
|
||||
|
||||
it('rejects a missing required field', () => {
|
||||
const { alias: _alias, ...withoutAlias } = validPreset;
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, { body: withoutAlias });
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects "editor" as a target — URL presets cannot point at the editor view', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, target: OntimeView.Editor },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('rejects an unknown target value', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validateNewPreset, {
|
||||
body: { ...validPreset, target: 'not-a-real-view' },
|
||||
});
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePresetParam', () => {
|
||||
it('accepts a non-empty alias param', () => {
|
||||
const { nextCalled } = runMiddleware(validatePresetParam, { params: { alias: 'my-preset' } });
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty alias param', () => {
|
||||
const { nextCalled, statusCode } = runMiddleware(validatePresetParam, { params: { alias: '' } });
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import type { NewPresetInput, PresetAliasParam, UpdatePresetInput } from './urlPresets.validation.js';
|
||||
import { validateNewPreset, validatePresetParam, validateUpdatePreset } from './urlPresets.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -14,80 +15,98 @@ router.get('/', (_req: Request, res: Response<URLPreset[]>) => {
|
||||
res.status(200).send(presets as URLPreset[]);
|
||||
});
|
||||
|
||||
router.post('/', validateNewPreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const newPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options,
|
||||
};
|
||||
router.post(
|
||||
'/',
|
||||
validateNewPreset,
|
||||
async (
|
||||
req: Request<unknown, URLPreset[] | ErrorResponse, NewPresetInput>,
|
||||
res: Response<URLPreset[] | ErrorResponse>,
|
||||
) => {
|
||||
try {
|
||||
const newPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options,
|
||||
};
|
||||
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
if (currentPresets.some((preset) => preset.alias === newPreset.alias)) {
|
||||
throw new Error(`Preset with alias ${newPreset.alias} already exists.`);
|
||||
}
|
||||
|
||||
const newPresets = [...currentPresets, newPreset];
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(201).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const newPresets = [...currentPresets, newPreset];
|
||||
router.put(
|
||||
'/:alias',
|
||||
validateUpdatePreset,
|
||||
async (
|
||||
req: Request<PresetAliasParam, URLPreset[] | ErrorResponse, UpdatePresetInput>,
|
||||
res: Response<URLPreset[] | ErrorResponse>,
|
||||
) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||
if (!existingPreset) {
|
||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||
}
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(201).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
|
||||
router.put('/:alias', validateUpdatePreset, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const existingPreset = currentPresets.find((preset) => preset.alias === alias);
|
||||
if (!existingPreset) {
|
||||
throw new Error(`Preset with alias ${alias} does not exist.`);
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
}
|
||||
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const updatedPreset: URLPreset = {
|
||||
enabled: req.body.enabled,
|
||||
alias: req.body.alias,
|
||||
target: req.body.target,
|
||||
search: req.body.search,
|
||||
displayInNav: req.body.displayInNav,
|
||||
options: req.body.options ?? existingPreset.options,
|
||||
};
|
||||
router.delete(
|
||||
'/:alias',
|
||||
validatePresetParam,
|
||||
async (req: Request<PresetAliasParam>, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||
|
||||
if (alias !== updatedPreset.alias) {
|
||||
throw new Error('Changing alias is not permitted');
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
|
||||
const newPresets = currentPresets.map((preset) => (preset.alias === alias ? updatedPreset : preset));
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:alias', validatePresetParam, async (req: Request, res: Response<URLPreset[] | ErrorResponse>) => {
|
||||
try {
|
||||
const alias = req.params.alias;
|
||||
const currentPresets = getDataProvider().getUrlPresets();
|
||||
const newPresets = currentPresets.filter((preset) => preset.alias !== alias);
|
||||
|
||||
// Update the URL presets in the data provider
|
||||
await getDataProvider().setUrlPresets(newPresets);
|
||||
sendRefetch(RefetchKey.UrlPresets);
|
||||
res.status(200).send(newPresets);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,40 +1,36 @@
|
||||
import { body, param } from 'express-validator';
|
||||
import { OntimeView } from 'ontime-types';
|
||||
import { OntimeView, type OntimeViewPresettable } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { validateBody, validateParams } from '../validation-utils/validate.js';
|
||||
|
||||
// URL presets cannot target the editor (see OntimeViewPresettable) — the previous
|
||||
// express-validator check allowed any OntimeView value including 'editor', which URLPreset's
|
||||
// own type never permitted; narrowed here now that the field is properly typed end to end.
|
||||
const presettableViews = Object.values(OntimeView).filter(
|
||||
(view): view is OntimeViewPresettable => view !== OntimeView.Editor,
|
||||
);
|
||||
|
||||
const presetOptionsSchema = z.record(z.string(), z.string()).optional();
|
||||
|
||||
/**
|
||||
* validate array of URL preset objects
|
||||
*/
|
||||
export const validateNewPreset = [
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
body('displayInNav').isBoolean(),
|
||||
|
||||
const newPresetSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
alias: z.string().trim().min(1),
|
||||
target: z.enum(presettableViews),
|
||||
search: z.string().trim(),
|
||||
displayInNav: z.boolean(),
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
options: presetOptionsSchema,
|
||||
});
|
||||
export type NewPresetInput = z.infer<typeof newPresetSchema>;
|
||||
export const validateNewPreset = validateBody(newPresetSchema);
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
const presetAliasParamSchema = z.object({ alias: z.string().trim().min(1) });
|
||||
export type PresetAliasParam = z.infer<typeof presetAliasParamSchema>;
|
||||
export const validatePresetParam = validateParams(presetAliasParamSchema);
|
||||
|
||||
export const validateUpdatePreset = [
|
||||
param('alias').isString().trim().notEmpty(),
|
||||
body().isObject().withMessage('No data found in request'),
|
||||
body('enabled').isBoolean(),
|
||||
body('alias').isString().trim().notEmpty(),
|
||||
body('target').isString().trim().notEmpty().isIn(Object.values(OntimeView)),
|
||||
body('search').isString().trim(),
|
||||
body('displayInNav').isBoolean(),
|
||||
|
||||
// options are currently only provided for cuesheet presets
|
||||
body('options').optional().isObject(),
|
||||
body('options.*').isString().trim(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validatePresetParam = [param('alias').isString().trim().notEmpty(), requestValidationFunction];
|
||||
// update reuses the same body shape as create, plus the alias param check
|
||||
export type UpdatePresetInput = NewPresetInput;
|
||||
export const validateUpdatePreset = [validateParams(presetAliasParamSchema), validateBody(newPresetSchema)];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Exercises a single Express middleware (e.g. validateBody(schema)) against a minimal
|
||||
* fake req/res, without standing up supertest or a running app — matches this codebase's
|
||||
* convention of testing validation logic directly rather than through an HTTP layer.
|
||||
*/
|
||||
export function runMiddleware(
|
||||
middleware: (req: Request, res: Response, next: NextFunction) => void,
|
||||
req: Partial<Request>,
|
||||
) {
|
||||
let statusCode: number | undefined;
|
||||
let payload: unknown;
|
||||
const res = {
|
||||
status(code: number) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(data: unknown) {
|
||||
payload = data;
|
||||
},
|
||||
} as Response;
|
||||
|
||||
let nextCalled = false;
|
||||
middleware(req as Request, res, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
return { nextCalled, statusCode, payload, req: req as Request };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { z, type ZodType } from 'zod';
|
||||
|
||||
type Target = 'body' | 'params';
|
||||
|
||||
/**
|
||||
* Builds an Express middleware that safe-parses req[target] against `schema`.
|
||||
* - Uses safeParse: no throw/catch on the hot invalid-input path.
|
||||
* - On success, replaces req[target] with the parsed value (defaults filled,
|
||||
* unknown keys stripped, .trim()/.transform() applied) and calls next().
|
||||
* - On failure, responds 422 with { errors: [...] }.
|
||||
*/
|
||||
function validate<T extends ZodType>(target: Target, schema: T) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const result = schema.safeParse(req[target]);
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map((issue) => ({
|
||||
location: target,
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
}));
|
||||
res.status(422).json({ errors });
|
||||
return;
|
||||
}
|
||||
req[target] = result.data;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export const validateBody = <T extends ZodType>(schema: T) => validate('body', schema);
|
||||
export const validateParams = <T extends ZodType>(schema: T) => validate('params', schema);
|
||||
|
||||
/** Direct replacement for the old paramsWithId */
|
||||
export const idParamSchema = z.object({ id: z.string().trim().min(1) });
|
||||
export const validateIdParam = validateParams(idParamSchema);
|
||||
|
||||
/**
|
||||
* Direct replacement for requestValidationFunctionWithFile — unrelated to Zod (it's a
|
||||
* check on multer's req.file, not on body/params shape), kept as its own middleware.
|
||||
*/
|
||||
export function requireUploadedFile(req: Request & { file?: unknown }, res: Response, next: NextFunction) {
|
||||
if (!req.file) {
|
||||
res.status(422).json({ errors: 'File not found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../api-data/project-data/projectData.dao.js', () => ({
|
||||
editCurrentProjectData: vi.fn(),
|
||||
getProjectData: vi.fn(() => ({ title: 'Test project' })),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.dao.js', () => ({
|
||||
getProjectCustomFields: vi.fn(() => ({})),
|
||||
getRundownMetadata: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../../api-data/rundown/rundown.service.js', () => ({
|
||||
createNewRundown: vi.fn(),
|
||||
deleteRundown: vi.fn(),
|
||||
duplicateExistingRundown: vi.fn(),
|
||||
loadRundown: vi.fn(),
|
||||
renameRundown: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => ({
|
||||
getDataProvider: vi.fn(() => ({ getProjectRundowns: () => ({}) })),
|
||||
}));
|
||||
|
||||
vi.mock('../../models/dataModel.js', () => ({
|
||||
makeNewProject: vi.fn(() => ({ project: {} })),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/project-service/ProjectService.js', () => ({
|
||||
createProjectWithPatch: vi.fn(),
|
||||
deleteProjectFile: vi.fn(),
|
||||
duplicateProjectFile: vi.fn(),
|
||||
getProjectList: vi.fn(),
|
||||
loadProjectFile: vi.fn(),
|
||||
renameProjectFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../stores/runtimeState.js', () => ({
|
||||
getState: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('../mcp.service.js', () => ({
|
||||
batchCreateEntriesForMcp: vi.fn(),
|
||||
batchUpdateEntriesForMcp: vi.fn(),
|
||||
createCustomFieldForMcp: vi.fn(),
|
||||
createEntryForMcp: vi.fn(),
|
||||
deleteCustomFieldForMcp: vi.fn(),
|
||||
deleteEntriesForMcp: vi.fn(),
|
||||
findEntry: vi.fn(),
|
||||
getRundownById: vi.fn(() => ({ id: 'r1', order: [], entries: {} })),
|
||||
groupEntriesForMcp: vi.fn(),
|
||||
reorderEntryForMcp: vi.fn(),
|
||||
toRundownList: vi.fn(),
|
||||
ungroupEntryForMcp: vi.fn(),
|
||||
updateCustomFieldForMcp: vi.fn(),
|
||||
updateEntryForMcp: vi.fn(),
|
||||
}));
|
||||
|
||||
const { TOOL_DEFINITIONS, handleToolCall } = await import('../mcp.tools.js');
|
||||
|
||||
describe('MCP tool schema generation', () => {
|
||||
it('generates a well-formed JSON Schema inputSchema for every tool', () => {
|
||||
expect(TOOL_DEFINITIONS.length).toBeGreaterThan(0);
|
||||
for (const tool of TOOL_DEFINITIONS) {
|
||||
expect(tool.inputSchema).toMatchObject({ type: 'object' });
|
||||
expect(typeof tool.inputSchema.properties).toBe('object');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP tool-call argument validation', () => {
|
||||
it('rejects a required field missing entirely (previously silently miscast)', async () => {
|
||||
const result = await handleToolCall('ontime_create_rundown', {});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a field with the wrong primitive type', async () => {
|
||||
const result = await handleToolCall('ontime_reorder_entry', {
|
||||
entryId: 'a',
|
||||
destinationId: 'b',
|
||||
order: 'sideways', // not one of before/after/insert
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown enum value on a nested field', async () => {
|
||||
const result = await handleToolCall('ontime_create_entry', {
|
||||
type: 'not-a-real-type',
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a minimal valid payload for a tool with no required fields', async () => {
|
||||
const result = await handleToolCall('ontime_get_rundown', {});
|
||||
expect(result.isError).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports unknown tool names distinctly from validation failures', async () => {
|
||||
const result = await handleToolCall('ontime_does_not_exist', {});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0]).toMatchObject({ type: 'text' });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Agent-facing Ontime MCP documentation.
|
||||
*
|
||||
@@ -9,70 +12,74 @@
|
||||
* Keep this file concise and update it when MCP-exposed fields change.
|
||||
*/
|
||||
|
||||
// ---- Shared event field JSON schemas ----
|
||||
// Imported by mcp.tools.ts and spread into tool inputSchema.properties.
|
||||
// ---- Shared event field schemas ----
|
||||
// Zod shape fragments, spread into z.object({...}) calls in mcp.tools.schema.ts.
|
||||
// Field descriptions carry through into the generated inputSchema (z.toJSONSchema) and
|
||||
// are what the MCP client/LLM actually reads — keep them in sync with reality.
|
||||
|
||||
export const EVENT_TIMER_FIELDS = {
|
||||
timerType: {
|
||||
type: 'string',
|
||||
enum: ['count-down', 'count-up', 'clock', 'none'],
|
||||
description: 'count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown',
|
||||
},
|
||||
endAction: {
|
||||
type: 'string',
|
||||
enum: ['none', 'load-next', 'play-next'],
|
||||
description: 'Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event',
|
||||
},
|
||||
linkStart: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
timerType: z
|
||||
.enum(TimerType)
|
||||
.optional()
|
||||
.describe('count-down: countdown from duration; count-up: elapsed time; clock: wall clock; none: no timer shown'),
|
||||
endAction: z
|
||||
.enum(EndAction)
|
||||
.optional()
|
||||
.describe('Action when event ends: none = stop, load-next = cue next event, play-next = auto-start next event'),
|
||||
linkStart: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Link this event's start time to the previous playable event's end time. Linked events allow time changes to propagate through the rundown. Unlinking would prevent propagation and lock this event's start time to the schedule",
|
||||
},
|
||||
countToEnd: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
),
|
||||
countToEnd: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Advanced timing mode: countdown targets the scheduled timeEnd instead of the event duration. This can surprise operators when an event starts late or the schedule shifts; only set true after explaining the behaviour and confirming the user wants it. This can be useful for a deadline, where an event always needs to end at the schedule time, ie: a curfew or a broadcast window.',
|
||||
},
|
||||
timeStrategy: {
|
||||
type: 'string',
|
||||
enum: ['lock-duration', 'lock-end'],
|
||||
description:
|
||||
),
|
||||
timeStrategy: z
|
||||
.enum(TimeStrategy)
|
||||
.optional()
|
||||
.describe(
|
||||
'How linked events adapt to an inherited start: lock-duration recalculates end, lock-end recalculates duration',
|
||||
},
|
||||
timeWarning: { type: 'number', description: 'ms before timeEnd to enter warning state (e.g. 300000 = 5 min)' },
|
||||
timeDanger: { type: 'number', description: 'ms before timeEnd to enter danger state (e.g. 60000 = 1 min)' },
|
||||
} as const;
|
||||
),
|
||||
timeWarning: z.number().optional().describe('ms before timeEnd to enter warning state (e.g. 300000 = 5 min)'),
|
||||
timeDanger: z.number().optional().describe('ms before timeEnd to enter danger state (e.g. 60000 = 1 min)'),
|
||||
};
|
||||
|
||||
export const EVENT_WRITABLE_FIELDS = {
|
||||
cue: { type: 'string', description: 'Short free-form cue label — ask the user what naming convention they prefer' },
|
||||
title: { type: 'string', description: 'Event title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text note for production notes or references' },
|
||||
colour: {
|
||||
type: 'string',
|
||||
description:
|
||||
cue: z.string().optional().describe('Short free-form cue label — ask the user what naming convention they prefer'),
|
||||
title: z.string().optional().describe('Event title shown in the rundown and views'),
|
||||
note: z.string().optional().describe('Free-text note for production notes or references'),
|
||||
colour: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Hex colour (#RRGGBB) for visual grouping — ask the user what colour convention they use, and prefer the default Ontime palette from ontime://style-guide so colours match the editor swatches',
|
||||
},
|
||||
skip: { 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:
|
||||
),
|
||||
skip: z.boolean().optional().describe('If true, event is skipped during playback'),
|
||||
flag: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Mark the event as a critical operational marker — use sparingly for maximum impact'),
|
||||
custom: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Custom field values keyed by project field key, e.g. { "Camera": "CAM 2" }. Keys are case-sensitive — get them with ontime_get_custom_fields, and create missing fields with ontime_create_custom_field.',
|
||||
},
|
||||
),
|
||||
...EVENT_TIMER_FIELDS,
|
||||
} as const;
|
||||
};
|
||||
|
||||
export const RUNDOWN_TARGET_FIELD = {
|
||||
rundownId: {
|
||||
type: 'string',
|
||||
description:
|
||||
rundownId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional target rundown ID. Omit to target the currently loaded live rundown; provide an ID from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
},
|
||||
} as const;
|
||||
),
|
||||
};
|
||||
|
||||
// ---- Agent-readable schema document ----
|
||||
// Served at ontime://schema. Agents read this once per session to orient themselves
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import {
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
InsertOptions,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
PatchWithId,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
@@ -29,40 +25,25 @@ import {
|
||||
} from '../api-data/rundown/rundown.service.js';
|
||||
import { normalisedToRundownArray } from '../api-data/rundown/rundown.utils.js';
|
||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
export type EventFieldArgs = Partial<
|
||||
Pick<
|
||||
OntimeEvent,
|
||||
| 'cue'
|
||||
| 'title'
|
||||
| 'note'
|
||||
| 'colour'
|
||||
| 'skip'
|
||||
| 'flag'
|
||||
| 'custom'
|
||||
| 'timerType'
|
||||
| 'endAction'
|
||||
| 'linkStart'
|
||||
| 'countToEnd'
|
||||
| 'timeStrategy'
|
||||
| 'timeWarning'
|
||||
| 'timeDanger'
|
||||
| 'timeStart'
|
||||
| 'timeEnd'
|
||||
| 'duration'
|
||||
>
|
||||
>;
|
||||
export type MilestoneFieldArgs = Partial<Pick<OntimeMilestone, 'cue' | 'title' | 'note' | 'colour' | 'custom'>>;
|
||||
export type DelayFieldArgs = Partial<Pick<OntimeDelay, 'duration'>>;
|
||||
export type GroupFieldArgs = Partial<Pick<OntimeGroup, 'title' | 'note' | 'colour' | 'targetDuration' | 'custom'>>;
|
||||
|
||||
export type EntryFieldArgs = EventFieldArgs & MilestoneFieldArgs & DelayFieldArgs & GroupFieldArgs;
|
||||
export type TargetRundownArgs = { rundownId?: string };
|
||||
export type CreateEntryArgs = EntryFieldArgs & InsertOptions & TargetRundownArgs & { type?: `${SupportedEntry}` };
|
||||
export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEntryArgs[] };
|
||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
||||
// *Args types are now derived from the Zod schemas in mcp.tools.schema.ts — that file is
|
||||
// the single source of truth for MCP tool input shape, validation, and typing.
|
||||
import type {
|
||||
BatchCreateEntriesArgs,
|
||||
BatchCreateEntryArgs,
|
||||
BatchUpdateEntriesArgs,
|
||||
CreateCustomFieldArgs,
|
||||
CreateEntryArgs,
|
||||
DeleteCustomFieldArgs,
|
||||
DeleteEntriesArgs,
|
||||
EntryFieldArgs,
|
||||
GetEntryArgs,
|
||||
GroupEntriesArgs,
|
||||
ReorderEntryArgs,
|
||||
TargetRundownArgs,
|
||||
UngroupEntryArgs,
|
||||
UpdateCustomFieldArgs,
|
||||
UpdateEntryArgs,
|
||||
} from './mcp.tools.schema.js';
|
||||
|
||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||
return args.rundownId ?? getCurrentRundownId();
|
||||
@@ -78,7 +59,7 @@ export function getRundownById(rundownId?: string): Readonly<Rundown> {
|
||||
return targetId === getCurrentRundownId() ? getCurrentRundown() : getDataProvider().getRundown(targetId);
|
||||
}
|
||||
|
||||
export function findEntry(args: TargetRundownArgs & { id?: EntryId; cue?: string }): OntimeEntry | undefined {
|
||||
export function findEntry(args: GetEntryArgs): OntimeEntry | undefined {
|
||||
const rundown = getRundownById(args.rundownId);
|
||||
if (args.id) {
|
||||
return rundown.entries[args.id];
|
||||
@@ -194,15 +175,13 @@ export async function updateEntryForMcp(args: UpdateEntryArgs) {
|
||||
return { target: getTargetMeta(rundownId), entry };
|
||||
}
|
||||
|
||||
export async function deleteEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[] }) {
|
||||
export async function deleteEntriesForMcp(args: DeleteEntriesArgs) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await deleteEntries(rundownId, args.ids);
|
||||
return { target: getTargetMeta(rundownId), deleted: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function reorderEntryForMcp(
|
||||
args: TargetRundownArgs & { entryId: EntryId; destinationId: EntryId; order: 'before' | 'after' | 'insert' },
|
||||
) {
|
||||
export async function reorderEntryForMcp(args: ReorderEntryArgs) {
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await reorderEntry(rundownId, args.entryId, args.destinationId, args.order);
|
||||
return { target: getTargetMeta(rundownId), order: rundown.order };
|
||||
@@ -259,9 +238,7 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function batchCreateEntriesForMcp(
|
||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
||||
) {
|
||||
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
||||
const { entries = [], after } = args;
|
||||
validateBatchCreateEntries(entries);
|
||||
const allEntries = flattenBatchCreateEntries(entries);
|
||||
@@ -336,14 +313,14 @@ async function createBatchEntry(
|
||||
return { entry, created };
|
||||
}
|
||||
|
||||
export async function batchUpdateEntriesForMcp(args: TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }) {
|
||||
export async function batchUpdateEntriesForMcp(args: BatchUpdateEntriesArgs) {
|
||||
assertKnownCustomFields(args.data.custom);
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
const rundown = await batchEditEntries(rundownId, args.ids, args.data);
|
||||
return { target: getTargetMeta(rundownId), updated: args.ids, order: rundown.order };
|
||||
}
|
||||
|
||||
export async function createCustomFieldForMcp(args: { label: string; type: 'text' | 'image'; colour: string }) {
|
||||
export async function createCustomFieldForMcp(args: CreateCustomFieldArgs) {
|
||||
const label = args.label?.trim();
|
||||
// same constraint the HTTP route enforces in customFields.validation.ts
|
||||
if (!label || !checkRegex.isAlphanumericWithSpace(label)) {
|
||||
@@ -364,13 +341,13 @@ export async function createCustomFieldForMcp(args: { label: string; type: 'text
|
||||
return { key, customFields: updated };
|
||||
}
|
||||
|
||||
export async function updateCustomFieldForMcp(args: { key: string; label?: string; colour?: string }) {
|
||||
export async function updateCustomFieldForMcp(args: UpdateCustomFieldArgs) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await editCustomField(args.key, { label: args.label, colour: args.colour }, projectRundowns);
|
||||
return { customFields: updated };
|
||||
}
|
||||
|
||||
export async function deleteCustomFieldForMcp(args: { key: string }) {
|
||||
export async function deleteCustomFieldForMcp(args: DeleteCustomFieldArgs) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const updated = await deleteCustomField(args.key, projectRundowns);
|
||||
return { customFields: updated };
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Zod schemas for MCP tool inputs.
|
||||
*
|
||||
* Each schema is the single source of truth for three things:
|
||||
* - the generated `inputSchema` served to MCP clients (via z.toJSONSchema in mcp.tools.ts)
|
||||
* - runtime validation of incoming tool-call arguments (via .safeParse in mcp.tools.ts)
|
||||
* - the TypeScript types used by mcp.service.ts's business logic
|
||||
*
|
||||
* Field shapes intentionally mirror the hand-written JSON Schema this file replaces, not
|
||||
* the full canonical domain types in ontime-types — e.g. `colour` stays a plain string,
|
||||
* matching what was (not) validated before this migration.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
||||
|
||||
// ---- Shared fragments ----
|
||||
|
||||
const entryTimingFields = {
|
||||
timeStart: z.number().optional().describe('Start time in ms from midnight'),
|
||||
timeEnd: z.number().optional().describe('End time in ms from midnight'),
|
||||
duration: z.number().optional().describe('Duration in ms'),
|
||||
targetDuration: z.number().optional().describe('Groups only: planned length of the group in ms'),
|
||||
};
|
||||
|
||||
// All writable fields of any entry type, flattened — reused as the base for create/update/
|
||||
// batch schemas via .extend(). Matches the previous EntryFieldArgs shape.
|
||||
const entryFieldsSchema = z.object({ ...entryTimingFields, ...EVENT_WRITABLE_FIELDS });
|
||||
export type EntryFieldArgs = z.infer<typeof entryFieldsSchema>;
|
||||
|
||||
const groupWritableFields = {
|
||||
title: z.string().optional().describe('Group title shown in the rundown and views'),
|
||||
note: z.string().optional().describe('Free-text group note for production notes or references'),
|
||||
colour: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide'),
|
||||
custom: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe('Custom field values keyed by existing project field key'),
|
||||
targetDuration: z.number().optional().describe('Planned length of the group in ms'),
|
||||
};
|
||||
|
||||
// ---- Rundown read ----
|
||||
|
||||
export const getRundownSchema = z.object({ ...RUNDOWN_TARGET_FIELD });
|
||||
export const getRundownMetadataSchema = z.object({});
|
||||
export const getEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().optional().describe('Entry ID (from rundown.entries key or entry.id)'),
|
||||
cue: z.string().optional().describe('Human-facing cue label'),
|
||||
});
|
||||
|
||||
// ---- Rundown mutations ----
|
||||
|
||||
export const createEntrySchema = entryFieldsSchema.extend({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
type: z
|
||||
.enum(['event', 'delay', 'milestone', 'group'])
|
||||
.optional()
|
||||
.describe(
|
||||
'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries',
|
||||
),
|
||||
// Overrides entryFieldsSchema's generic timing descriptions with create-specific guidance.
|
||||
timeStart: z.number().optional().describe('Event start time in ms from midnight (e.g. 09:00 = 32400000)'),
|
||||
timeEnd: z.number().optional().describe('Event end time in ms from midnight'),
|
||||
duration: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)'),
|
||||
after: z.string().optional().describe('Insert after this entry ID'),
|
||||
before: z.string().optional().describe('Insert before this entry ID'),
|
||||
});
|
||||
|
||||
export const updateEntrySchema = entryFieldsSchema.extend({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().describe('ID of the entry to update'),
|
||||
});
|
||||
|
||||
export const deleteEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Array of entry IDs to delete'),
|
||||
});
|
||||
|
||||
export const reorderEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
entryId: z.string().describe('ID of the entry to move'),
|
||||
destinationId: z.string().describe('ID of the target entry (sibling or parent group)'),
|
||||
order: z.enum(['before', 'after', 'insert']).describe('before/after: place as sibling; insert: place inside a group'),
|
||||
});
|
||||
|
||||
export const groupEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Existing top-level entry IDs to group'),
|
||||
...groupWritableFields,
|
||||
});
|
||||
|
||||
export const ungroupEntrySchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: z.string().describe('Group entry ID to dissolve'),
|
||||
});
|
||||
|
||||
// Recursive: a group entry in a batch may include `children` of the same shape. `type`/
|
||||
// `children` are declared outside entryFieldsSchema so the lazy() wrapper can reference
|
||||
// the schema being defined.
|
||||
export interface BatchCreateEntryArgs extends EntryFieldArgs {
|
||||
type?: 'event' | 'delay' | 'milestone' | 'group';
|
||||
children?: BatchCreateEntryArgs[];
|
||||
}
|
||||
|
||||
export const batchCreateEntrySchema: z.ZodType<BatchCreateEntryArgs> = z.lazy(() =>
|
||||
entryFieldsSchema.extend({
|
||||
type: z.enum(['event', 'delay', 'milestone', 'group']).optional().describe('Entry type, defaults to event'),
|
||||
children: z
|
||||
.array(batchCreateEntrySchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
export const batchCreateEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: z.string().optional().describe('Insert the first entry after this entry ID'),
|
||||
entries: z.array(batchCreateEntrySchema).describe('Array of entries to create, in desired order'),
|
||||
});
|
||||
|
||||
export const batchUpdateEntriesSchema = z.object({
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: z.array(z.string()).describe('Array of entry IDs to update'),
|
||||
data: entryFieldsSchema.describe('Partial entry fields to apply to every ID'),
|
||||
});
|
||||
|
||||
// ---- Rundown management ----
|
||||
|
||||
export const listRundownsSchema = z.object({});
|
||||
export const createRundownSchema = z.object({ title: z.string().describe('Title for the new rundown') });
|
||||
export const loadRundownSchema = z.object({ id: z.string().describe('Rundown ID to load') });
|
||||
export const renameRundownSchema = z.object({
|
||||
id: z.string().describe('Rundown ID to rename'),
|
||||
title: z.string().describe('New title'),
|
||||
});
|
||||
export const deleteRundownSchema = z.object({ id: z.string().describe('Rundown ID to delete') });
|
||||
export const duplicateRundownSchema = z.object({ id: z.string().describe('Rundown ID to duplicate') });
|
||||
|
||||
// ---- Timer & project ----
|
||||
|
||||
export const getTimerStateSchema = z.object({});
|
||||
export const getProjectInfoSchema = z.object({});
|
||||
export const updateProjectInfoSchema = z.object({
|
||||
title: z.string().optional().describe('Project title'),
|
||||
description: z.string().optional().describe('Project description'),
|
||||
url: z.string().optional().describe('URL shown on viewer pages'),
|
||||
info: z.string().optional().describe('Info text shown on viewer pages'),
|
||||
});
|
||||
export const getCustomFieldsSchema = z.object({});
|
||||
export const createCustomFieldSchema = z.object({
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
|
||||
),
|
||||
type: z
|
||||
.enum(['text', 'image'])
|
||||
.describe(
|
||||
'Field type — cannot be changed after creation. Use "text" for short text values; "image" for image URLs.',
|
||||
),
|
||||
colour: z
|
||||
.string()
|
||||
.describe(
|
||||
'Hex colour (#RRGGBB) used to visually identify this column in the cuesheet — for department fields, match the department colour convention (see ontime://style-guide).',
|
||||
),
|
||||
});
|
||||
export const updateCustomFieldSchema = z.object({
|
||||
key: z.string().describe('Current field key (from ontime_get_custom_fields)'),
|
||||
label: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New human-readable label (optional). Changes the derived key and cascades to all entries.'),
|
||||
colour: z.string().optional().describe('New hex colour (#RRGGBB) (optional)'),
|
||||
});
|
||||
export const deleteCustomFieldSchema = z.object({
|
||||
key: z.string().describe('Field key to delete (from ontime_get_custom_fields)'),
|
||||
});
|
||||
|
||||
// ---- Project file management ----
|
||||
|
||||
export const listProjectsSchema = z.object({});
|
||||
export const loadProjectSchema = z.object({
|
||||
filename: z.string().describe('Project filename, e.g. "my-show.json"'),
|
||||
});
|
||||
export const createProjectSchema = z.object({
|
||||
filename: z.string().describe('Filename without extension, e.g. "my-show"'),
|
||||
title: z.string().optional().describe('Optional project title'),
|
||||
description: z.string().optional().describe('Optional project description'),
|
||||
});
|
||||
export const renameProjectSchema = z.object({
|
||||
filename: z.string().describe('Current filename (with .json extension)'),
|
||||
newFilename: z.string().describe('New filename (with .json extension)'),
|
||||
});
|
||||
export const duplicateProjectSchema = z.object({
|
||||
filename: z.string().describe('Source filename to copy (with .json extension)'),
|
||||
newFilename: z.string().describe('Filename of the new copy (with .json extension)'),
|
||||
});
|
||||
export const deleteProjectSchema = z.object({
|
||||
filename: z.string().describe('Project filename to delete (with .json extension)'),
|
||||
});
|
||||
|
||||
// ---- Inferred types consumed by mcp.service.ts (replaces its hand-written *Args types) ----
|
||||
|
||||
export type TargetRundownArgs = z.infer<typeof getRundownSchema>;
|
||||
export type GetEntryArgs = z.infer<typeof getEntrySchema>;
|
||||
export type CreateEntryArgs = z.infer<typeof createEntrySchema>;
|
||||
export type UpdateEntryArgs = z.infer<typeof updateEntrySchema>;
|
||||
export type DeleteEntriesArgs = z.infer<typeof deleteEntriesSchema>;
|
||||
export type ReorderEntryArgs = z.infer<typeof reorderEntrySchema>;
|
||||
export type GroupEntriesArgs = z.infer<typeof groupEntriesSchema>;
|
||||
export type UngroupEntryArgs = z.infer<typeof ungroupEntrySchema>;
|
||||
export type BatchCreateEntriesArgs = z.infer<typeof batchCreateEntriesSchema>;
|
||||
export type BatchUpdateEntriesArgs = z.infer<typeof batchUpdateEntriesSchema>;
|
||||
export type ProjectInfoArgs = z.infer<typeof updateProjectInfoSchema>;
|
||||
export type CreateCustomFieldArgs = z.infer<typeof createCustomFieldSchema>;
|
||||
export type UpdateCustomFieldArgs = z.infer<typeof updateCustomFieldSchema>;
|
||||
export type DeleteCustomFieldArgs = z.infer<typeof deleteCustomFieldSchema>;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { EntryId, ProjectData } from 'ontime-types';
|
||||
import { ProjectData } from 'ontime-types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { editCurrentProjectData, getProjectData } from '../api-data/project-data/projectData.dao.js';
|
||||
import { getProjectCustomFields, getRundownMetadata } from '../api-data/rundown/rundown.dao.js';
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
renameProjectFile,
|
||||
} from '../services/project-service/ProjectService.js';
|
||||
import { getState } from '../stores/runtimeState.js';
|
||||
import { EVENT_WRITABLE_FIELDS, RUNDOWN_TARGET_FIELD } from './mcp.schema.js';
|
||||
import {
|
||||
batchCreateEntriesForMcp,
|
||||
batchUpdateEntriesForMcp,
|
||||
@@ -37,14 +37,19 @@ import {
|
||||
ungroupEntryForMcp,
|
||||
updateCustomFieldForMcp,
|
||||
updateEntryForMcp,
|
||||
type BatchCreateEntryArgs,
|
||||
type CreateEntryArgs,
|
||||
type EntryFieldArgs,
|
||||
type GroupEntriesArgs,
|
||||
type TargetRundownArgs,
|
||||
type UngroupEntryArgs,
|
||||
type UpdateEntryArgs,
|
||||
} from './mcp.service.js';
|
||||
import * as schemas from './mcp.tools.schema.js';
|
||||
|
||||
/**
|
||||
* Parses tool-call arguments against `schema`, throwing on failure. handleToolCall (below)
|
||||
* already wraps every handler in try/catch and formats thrown errors into a CallToolResult,
|
||||
* so this reuses that existing error path rather than inventing a second one — unlike the
|
||||
* REST validation layer (apps/server/src/api-data/validation-utils/validate.ts), tool calls
|
||||
* are not a hot request path, so .parse()'s throw-based control flow costs nothing here.
|
||||
*/
|
||||
function parseArgs<T extends z.ZodType>(schema: T, args: Record<string, unknown>): z.infer<T> {
|
||||
return schema.parse(args);
|
||||
}
|
||||
|
||||
// Graceful truncation to keep tool responses within typical MCP context windows
|
||||
const CHARACTER_LIMIT = 25_000;
|
||||
@@ -68,28 +73,21 @@ export const TOOL_DEFINITIONS = [
|
||||
name: 'ontime_get_rundown',
|
||||
description:
|
||||
'Get a rundown. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns { order: EntryId[], entries: { [id]: OntimeEntry } }. If the rundown exceeds 25 000 chars, returns only the order array with a warning — fetch individual entries with ontime_get_entry.',
|
||||
inputSchema: { type: 'object', properties: { ...RUNDOWN_TARGET_FIELD } },
|
||||
inputSchema: z.toJSONSchema(schemas.getRundownSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_rundown_metadata',
|
||||
description:
|
||||
'Get cached metadata for the current rundown. Returns: totalDelay, totalDuration, totalDays, firstStart, lastEnd, flags (flagged entry IDs), playableEventOrder, timedEventOrder, flatEntryOrder.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getRundownMetadataSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_entry',
|
||||
description:
|
||||
'Get a single entry by id or cue. Provide either id or cue (not both). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to read a background rundown. Returns the full entry object.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Entry ID (from rundown.entries key or entry.id)' },
|
||||
cue: { type: 'string', description: 'Human-facing cue label' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.getEntrySchema),
|
||||
annotations: READ,
|
||||
},
|
||||
// --- Rundown mutations ---
|
||||
@@ -97,186 +95,56 @@ export const TOOL_DEFINITIONS = [
|
||||
name: 'ontime_create_entry',
|
||||
description:
|
||||
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description:
|
||||
'Entry type, defaults to event. event: timed show item; milestone: non-timed marker; delay: schedule shift; group: named container of entries',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight (e.g. 09:00 = 32400000)' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: {
|
||||
type: 'number',
|
||||
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
after: { type: 'string', description: 'Insert after this entry ID' },
|
||||
before: { type: 'string', description: 'Insert before this entry ID' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createEntrySchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_entry',
|
||||
description:
|
||||
'Update fields of an existing entry (event, milestone, delay or group). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Only provided fields are changed. Event time fields (timeStart, timeEnd, duration) are reconciled server-side — you may provide any combination. Group fields: title, note, colour, custom, targetDuration. Delay field: duration. Milestone fields: cue, title, note, colour, custom. Custom values must use existing project custom field keys; adding a new custom field is a separate operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'ID of the entry to update' },
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateEntrySchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_entries',
|
||||
description:
|
||||
'Delete one or more entries (events, milestones, delays, or groups). Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to delete' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteEntriesSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_reorder_entry',
|
||||
description:
|
||||
'Move an entry to a new position relative to another entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use before/after for sibling reordering; use insert for targeted moves into a group. For grouping several existing top-level entries, prefer ontime_group_entries.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entryId', 'destinationId', 'order'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
entryId: { type: 'string', description: 'ID of the entry to move' },
|
||||
destinationId: { type: 'string', description: 'ID of the target entry (sibling or parent group)' },
|
||||
order: {
|
||||
type: 'string',
|
||||
enum: ['before', 'after', 'insert'],
|
||||
description: 'before/after: place as sibling; insert: place inside a group',
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.reorderEntrySchema),
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_group_entries',
|
||||
description:
|
||||
'Create a group from existing top-level entries. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Entries must be existing top-level non-group entries; groups cannot be nested. Optional title, note, colour, custom, and targetDuration are applied to the created group.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Existing top-level entry IDs to group' },
|
||||
title: { type: 'string', description: 'Group title shown in the rundown and views' },
|
||||
note: { type: 'string', description: 'Free-text group note for production notes or references' },
|
||||
colour: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Hex colour (#RRGGBB) for the group — prefer the default Ontime palette from ontime://style-guide',
|
||||
},
|
||||
custom: {
|
||||
type: 'object',
|
||||
additionalProperties: { type: 'string' },
|
||||
description: 'Custom field values keyed by existing project field key',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Planned length of the group in ms' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.groupEntriesSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_ungroup_entry',
|
||||
description:
|
||||
'Dissolve a group by moving its children to the top level where the group was. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
id: { type: 'string', description: 'Group entry ID to dissolve' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.ungroupEntrySchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_create_entries',
|
||||
description:
|
||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entries'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
|
||||
entries: {
|
||||
type: 'array',
|
||||
description: 'Array of entries to create, in desired order',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['event', 'delay', 'milestone', 'group'],
|
||||
description: 'Entry type, defaults to event',
|
||||
},
|
||||
timeStart: { type: 'number', description: 'Event start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'Event end time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
children: {
|
||||
type: 'array',
|
||||
description:
|
||||
'For group entries only: child events, milestones, or delays to create inside this group in order. Nested groups are not supported.',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.batchCreateEntriesSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_batch_update_entries',
|
||||
description:
|
||||
'Apply the same field values to multiple entries by ID. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. Use for bulk operations like recolouring all keynotes, skipping all breaks, or setting the same custom value on several entries. Custom values must use existing project custom field keys. Do not use for changes where each entry needs a different value, such as time shifts with different timeStart/timeEnd values; compute those per entry and call ontime_update_entry for each.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['ids', 'data'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
ids: { type: 'array', items: { type: 'string' }, description: 'Array of entry IDs to update' },
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Partial entry fields to apply to every ID',
|
||||
properties: {
|
||||
timeStart: { type: 'number', description: 'Start time in ms from midnight' },
|
||||
timeEnd: { type: 'number', description: 'End time in ms from midnight' },
|
||||
duration: { type: 'number', description: 'Duration in ms' },
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.batchUpdateEntriesSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Rundown management ---
|
||||
@@ -284,62 +152,39 @@ export const TOOL_DEFINITIONS = [
|
||||
name: 'ontime_list_rundowns',
|
||||
description:
|
||||
'List all rundowns in the current project. Returns rundown IDs and titles, plus the ID of the currently loaded one.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.listRundownsSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_rundown',
|
||||
description:
|
||||
'Create a new empty rundown in the current project. Does not switch to it — use ontime_load_rundown to activate.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['title'],
|
||||
properties: { title: { type: 'string', description: 'Title for the new rundown' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createRundownSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_rundown',
|
||||
description:
|
||||
'Make a rundown the active rundown. This resets the runtime and clears playback state. If playback is running, confirm the user accepts interrupting the live rundown before calling. To edit a background rundown without interrupting playback, advise using the cuesheet view.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to load' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.loadRundownSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_rundown',
|
||||
description: 'Rename an existing rundown',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id', 'title'],
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Rundown ID to rename' },
|
||||
title: { type: 'string', description: 'New title' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.renameRundownSchema),
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_rundown',
|
||||
description: 'Delete a rundown (cannot delete the currently loaded rundown or the last remaining rundown)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to delete' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteRundownSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_rundown',
|
||||
description: 'Duplicate a rundown, creating a copy with a new ID. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', description: 'Rundown ID to duplicate' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.duplicateRundownSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
// --- Timer & project ---
|
||||
@@ -347,171 +192,93 @@ export const TOOL_DEFINITIONS = [
|
||||
name: 'ontime_get_timer_state',
|
||||
description:
|
||||
'Get the current timer/playback state. Returns: clock (time of day), timer ({ playback, current, elapsed, phase, expectedFinish, addedTime, startedAt }), eventNow (full event object or null), eventNext (full event object or null), offset.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getTimerStateSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_project_info',
|
||||
description:
|
||||
'Get current project metadata: title, description, url, info, logo, and custom header fields (array of { title, value, url }).',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getProjectInfoSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_project_info',
|
||||
description: 'Update project metadata fields. All fields are optional — only provided fields are updated.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Project title' },
|
||||
description: { type: 'string', description: 'Project description' },
|
||||
url: { type: 'string', description: 'URL shown on viewer pages' },
|
||||
info: { type: 'string', description: 'Info text shown on viewer pages' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateProjectInfoSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_get_custom_fields',
|
||||
description:
|
||||
'Get the project custom field definitions. Returns { [key]: { label, type: "text"|"image", colour } }. Keys are referenced in entry.custom[key].',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.getCustomFieldsSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_custom_field',
|
||||
description:
|
||||
'Create a new project-level custom field definition. Custom fields add typed columns to every entry in all rundowns. The key is auto-derived from the label (spaces → underscores, e.g. "Camera Angle" → "Camera_Angle"). Creation is non-destructive — check ontime_get_custom_fields for an existing field covering the concept, and if none exists create directly without asking the user. After creation, use the returned key in entry.custom.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['label', 'type', 'colour'],
|
||||
properties: {
|
||||
label: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Human-readable label (letters, numbers and spaces, e.g. "Camera"). Determines the key. Reuse an existing field over creating near-duplicates like "Cam", "camera", "Cameras".',
|
||||
},
|
||||
type: {
|
||||
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 — for department fields, match the department colour convention (see ontime://style-guide).',
|
||||
},
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createCustomFieldSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_update_custom_field',
|
||||
description:
|
||||
'Update a custom field label or colour. Changing the label renames the derived key (spaces → underscores) and updates all entry references across all rundowns. Field type cannot be changed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Current field key (from ontime_get_custom_fields)' },
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'New human-readable label (optional). Changes the derived key and cascades to all entries.',
|
||||
},
|
||||
colour: { type: 'string', description: 'New hex colour (#RRGGBB) (optional)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.updateCustomFieldSchema),
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_custom_field',
|
||||
description:
|
||||
'Delete a custom field definition and remove its values from all entries in all rundowns. Destructive and cannot be undone — confirm with the user before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
properties: {
|
||||
key: { type: 'string', description: 'Field key to delete (from ontime_get_custom_fields)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteCustomFieldSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
// --- Project file management ---
|
||||
{
|
||||
name: 'ontime_list_projects',
|
||||
description: 'List all project files on disk. Returns filenames, timestamps, and the last-loaded project name.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
inputSchema: z.toJSONSchema(schemas.listProjectsSchema),
|
||||
annotations: READ,
|
||||
},
|
||||
{
|
||||
name: 'ontime_load_project',
|
||||
description:
|
||||
'Load a different project file by filename. This swaps the database and reinitialises runtime. If playback is running, confirm the user accepts interrupting the live project before calling.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename, e.g. "my-show.json"' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.loadProjectSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_create_project',
|
||||
description:
|
||||
'Create a new project file and switch to it. This swaps the loaded project. If playback is running, confirm the user accepts interrupting the live project before calling. Omit the .json extension — Ontime appends it.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Filename without extension, e.g. "my-show"' },
|
||||
title: { type: 'string', description: 'Optional project title' },
|
||||
description: { type: 'string', description: 'Optional project description' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.createProjectSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_rename_project',
|
||||
description: 'Rename a project file. If the renamed project is currently loaded, it is reloaded with the new name.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Current filename (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'New filename (with .json extension)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.renameProjectSchema),
|
||||
annotations: WRITE_IDEM,
|
||||
},
|
||||
{
|
||||
name: 'ontime_duplicate_project',
|
||||
description: 'Duplicate a project file on disk with a new filename. Does not switch to the copy.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename', 'newFilename'],
|
||||
properties: {
|
||||
filename: { type: 'string', description: 'Source filename to copy (with .json extension)' },
|
||||
newFilename: { type: 'string', description: 'Filename of the new copy (with .json extension)' },
|
||||
},
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.duplicateProjectSchema),
|
||||
annotations: WRITE,
|
||||
},
|
||||
{
|
||||
name: 'ontime_delete_project',
|
||||
description: 'Delete a project file from disk. Fails if the file is currently loaded.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['filename'],
|
||||
properties: { filename: { type: 'string', description: 'Project filename to delete (with .json extension)' } },
|
||||
},
|
||||
inputSchema: z.toJSONSchema(schemas.deleteProjectSchema),
|
||||
annotations: WRITE_DESTRUCTIVE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ToolName = (typeof TOOL_DEFINITIONS)[number]['name'];
|
||||
|
||||
type ProjectInfoArgs = Partial<Pick<ProjectData, 'title' | 'description' | 'url' | 'info'>>;
|
||||
|
||||
// ---- Response helpers (module-level to avoid re-allocation on every tool call) ----
|
||||
|
||||
const text = (data: unknown): string => JSON.stringify(data);
|
||||
@@ -528,7 +295,7 @@ export const err = (e: unknown): CallToolResult => ({
|
||||
// into an existing service and formats the response. Business logic belongs in the services.
|
||||
const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise<CallToolResult>> = {
|
||||
ontime_get_rundown: async (args) => {
|
||||
const targetArgs = args as TargetRundownArgs;
|
||||
const targetArgs = parseArgs(schemas.getRundownSchema, args);
|
||||
const rundown = getRundownById(targetArgs.rundownId);
|
||||
const data = { order: rundown.order, entries: rundown.entries };
|
||||
const serialised = text(data);
|
||||
@@ -546,7 +313,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
ontime_get_rundown_metadata: async () => ok(getRundownMetadata()),
|
||||
|
||||
ontime_get_entry: async (args) => {
|
||||
const entryArgs = args as TargetRundownArgs & { id?: EntryId; cue?: string };
|
||||
const entryArgs = parseArgs(schemas.getEntrySchema, args);
|
||||
const entry = findEntry(entryArgs);
|
||||
if (entry) return ok(entry);
|
||||
if (entryArgs.id) return err(`No entry with id ${entryArgs.id}`);
|
||||
@@ -555,71 +322,61 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
},
|
||||
|
||||
ontime_create_entry: async (args) => {
|
||||
return ok(await createEntryForMcp(args as CreateEntryArgs));
|
||||
return ok(await createEntryForMcp(parseArgs(schemas.createEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_update_entry: async (args) => {
|
||||
return ok(await updateEntryForMcp(args as UpdateEntryArgs));
|
||||
return ok(await updateEntryForMcp(parseArgs(schemas.updateEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_delete_entries: async (args) => {
|
||||
return ok(await deleteEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[] }));
|
||||
return ok(await deleteEntriesForMcp(parseArgs(schemas.deleteEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_reorder_entry: async (args) => {
|
||||
return ok(
|
||||
await reorderEntryForMcp(
|
||||
args as TargetRundownArgs & {
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
},
|
||||
),
|
||||
);
|
||||
return ok(await reorderEntryForMcp(parseArgs(schemas.reorderEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_group_entries: async (args) => {
|
||||
return ok(await groupEntriesForMcp(args as GroupEntriesArgs));
|
||||
return ok(await groupEntriesForMcp(parseArgs(schemas.groupEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_ungroup_entry: async (args) => {
|
||||
return ok(await ungroupEntryForMcp(args as UngroupEntryArgs));
|
||||
return ok(await ungroupEntryForMcp(parseArgs(schemas.ungroupEntrySchema, args)));
|
||||
},
|
||||
|
||||
ontime_batch_create_entries: async (args) => {
|
||||
return ok(
|
||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
||||
);
|
||||
return ok(await batchCreateEntriesForMcp(parseArgs(schemas.batchCreateEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_batch_update_entries: async (args) => {
|
||||
return ok(await batchUpdateEntriesForMcp(args as TargetRundownArgs & { ids: EntryId[]; data: EntryFieldArgs }));
|
||||
return ok(await batchUpdateEntriesForMcp(parseArgs(schemas.batchUpdateEntriesSchema, args)));
|
||||
},
|
||||
|
||||
ontime_list_rundowns: async () => ok(toRundownList(getDataProvider().getProjectRundowns())),
|
||||
|
||||
ontime_create_rundown: async (args) => {
|
||||
const { title } = args as { title: string };
|
||||
const { title } = parseArgs(schemas.createRundownSchema, args);
|
||||
return ok(toRundownList(await createNewRundown(title)));
|
||||
},
|
||||
|
||||
ontime_load_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.loadRundownSchema, args);
|
||||
return ok(toRundownList(await loadRundown(id)));
|
||||
},
|
||||
|
||||
ontime_rename_rundown: async (args) => {
|
||||
const { id, title } = args as { id: string; title: string };
|
||||
const { id, title } = parseArgs(schemas.renameRundownSchema, args);
|
||||
return ok(toRundownList(await renameRundown(id, title)));
|
||||
},
|
||||
|
||||
ontime_delete_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.deleteRundownSchema, args);
|
||||
return ok(toRundownList(await deleteRundown(id)));
|
||||
},
|
||||
|
||||
ontime_duplicate_rundown: async (args) => {
|
||||
const { id } = args as { id: string };
|
||||
const { id } = parseArgs(schemas.duplicateRundownSchema, args);
|
||||
return ok(toRundownList(await duplicateExistingRundown(id)));
|
||||
},
|
||||
|
||||
@@ -631,61 +388,53 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
ontime_get_project_info: async () => ok(getProjectData()),
|
||||
|
||||
ontime_update_project_info: async (args) => {
|
||||
const updated = await editCurrentProjectData(args as ProjectInfoArgs);
|
||||
const updated = await editCurrentProjectData(parseArgs(schemas.updateProjectInfoSchema, args));
|
||||
return ok(updated);
|
||||
},
|
||||
|
||||
ontime_get_custom_fields: async () => ok(getProjectCustomFields()),
|
||||
|
||||
ontime_create_custom_field: async (args) => {
|
||||
return ok(await createCustomFieldForMcp(args as { label: string; type: 'text' | 'image'; colour: string }));
|
||||
return ok(await createCustomFieldForMcp(parseArgs(schemas.createCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_update_custom_field: async (args) => {
|
||||
return ok(await updateCustomFieldForMcp(args as { key: string; label?: string; colour?: string }));
|
||||
return ok(await updateCustomFieldForMcp(parseArgs(schemas.updateCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_delete_custom_field: async (args) => {
|
||||
return ok(await deleteCustomFieldForMcp(args as { key: string }));
|
||||
return ok(await deleteCustomFieldForMcp(parseArgs(schemas.deleteCustomFieldSchema, args)));
|
||||
},
|
||||
|
||||
ontime_list_projects: async () => ok(await getProjectList()),
|
||||
|
||||
ontime_load_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
const { filename } = parseArgs(schemas.loadProjectSchema, args);
|
||||
await loadProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_create_project: async (args) => {
|
||||
const {
|
||||
filename,
|
||||
title = '',
|
||||
description = '',
|
||||
} = args as {
|
||||
filename: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
const { filename, title = '', description = '' } = parseArgs(schemas.createProjectSchema, args);
|
||||
const project: ProjectData = { ...makeNewProject().project, title, description };
|
||||
const newFileName = await createProjectWithPatch(filename, { project });
|
||||
return ok({ filename: newFileName });
|
||||
},
|
||||
|
||||
ontime_rename_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
const { filename, newFilename } = parseArgs(schemas.renameProjectSchema, args);
|
||||
await renameProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_duplicate_project: async (args) => {
|
||||
const { filename, newFilename } = args as { filename: string; newFilename: string };
|
||||
const { filename, newFilename } = parseArgs(schemas.duplicateProjectSchema, args);
|
||||
await duplicateProjectFile(filename, newFilename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
ontime_delete_project: async (args) => {
|
||||
const { filename } = args as { filename: string };
|
||||
const { filename } = parseArgs(schemas.deleteProjectSchema, args);
|
||||
await deleteProjectFile(filename);
|
||||
return ok(await getProjectList());
|
||||
},
|
||||
|
||||
@@ -111,9 +111,9 @@ export type OntimeAction =
|
||||
text?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
// TODO: when setting a secondary source of type secondary we could specify a value to it
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: OntimeMessageSecondary;
|
||||
secondarySource?: SecondarySource;
|
||||
text?: string;
|
||||
secondarySource: SecondarySource;
|
||||
};
|
||||
|
||||
Generated
+6
@@ -21,6 +21,9 @@ catalogs:
|
||||
vitest:
|
||||
specifier: 4.0.17
|
||||
version: 4.0.17
|
||||
zod:
|
||||
specifier: 4.4.3
|
||||
version: 4.4.3
|
||||
|
||||
importers:
|
||||
|
||||
@@ -275,6 +278,9 @@ importers:
|
||||
xlsx:
|
||||
specifier: ^0.18.5
|
||||
version: 0.18.5
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@types/cookie-parser':
|
||||
specifier: 1.4.10
|
||||
|
||||
@@ -8,6 +8,7 @@ catalog:
|
||||
ts-essentials: 10.1.1
|
||||
typescript: 7.0.2
|
||||
vitest: 4.0.17
|
||||
zod: 4.4.3
|
||||
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
|
||||
Reference in New Issue
Block a user