Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 972a246bb6 feat(server): introduce Zod request validation, starting with MCP tools
Adds Zod as the validation library for apps/server (in place of
express-validator), scoped here to a low-risk slice rather than a
full migration:

- MCP tool-call arguments (apps/server/src/api-mcp) are now validated
  end to end. Every tool's inputSchema is generated from its Zod
  schema via z.toJSONSchema() instead of hand-maintained JSON Schema
  literals, and every handler now parses its arguments before use —
  previously every handler did an unchecked `args as SomeType` cast
  with no runtime validation at all.
- A new validate.ts middleware (validateBody/validateParams) replaces
  the express-validator chokepoint for two reference routers, session
  and url-presets, demonstrating the pattern the remaining ~11 routers
  will follow in later PRs.
- An oxlint no-restricted-imports guardrail keeps zod importable only
  from apps/server, so it can never leak into the apps/client bundle.

The bulk of the REST router migration (automation, rundown, and the
rest of api-data) is intentionally left for follow-up PRs to keep this
change reviewable.
2026-07-18 13:34:07 +00:00
130 changed files with 1958 additions and 4579 deletions
-5
View File
@@ -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
View File
@@ -23,7 +23,7 @@
"no-restricted-imports": [
"error",
{
"patterns": ["ontime-types/src/*", "ontime-utils/src/*"]
"patterns": ["ontime-types/src/*", "ontime-utils/src/*", "zod"]
}
]
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.11.0",
"version": "4.10.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.11.0",
"version": "4.10.0",
"private": true,
"type": "module",
"dependencies": {
-10
View File
@@ -6,7 +6,6 @@ import {
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
TransientEventPayload,
} from 'ontime-types';
@@ -83,15 +82,6 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
return axios.delete(`${rundownPath}/${rundownId}`);
}
/**
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
*/
export async function importRundownWithOptions(
payload: RundownImportPayload,
): Promise<AxiosResponse<ProjectRundownsList>> {
return axios.post(`${rundownPath}/import`, payload);
}
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -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>;
}
@@ -20,7 +20,7 @@
.wide {
top: 4vh;
min-width: min(1280px, 96vw);
max-width: min(1800px, 98vw);
max-width: min(1600px, 96vw);
height: 88vh;
max-height: 88vh;
display: flex;
@@ -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}`}>
@@ -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 (0100) 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);
}
+7 -13
View File
@@ -1,6 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
MaybeString,
OntimeDelay,
OntimeEntry,
@@ -59,15 +60,9 @@ export type EventOptions = Partial<{
before: MaybeString;
// options of entries of type OntimeEvent
linkPrevious: boolean;
/** the timing reference for the new entry, usually the same as after */
lastEventId: MaybeString;
}>;
type ClientInsertOptions = {
after?: EntryId;
before?: EntryId;
};
/**
* Gather utilities for actions on entries in the loaded rundown.
*/
@@ -125,7 +120,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* @private
*/
const { mutateAsync: addEntryMutation } = useMutation({
mutationFn: ([rundownId, entry]: [string, PatchWithId & ClientInsertOptions]) => postAddEntry(rundownId, entry),
mutationFn: ([rundownId, entry]: [string, PatchWithId & InsertOptions]) => postAddEntry(rundownId, entry),
onMutate: async ([_rundownId, entry]) => {
const queryKey = resolveCurrentRundownQueryKey();
await queryClient.cancelQueries({ queryKey });
@@ -154,9 +149,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
addToRundown(
newRundown,
optimisticEntry,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
afterId,
entry.before ?? null,
parent ? (newRundown.entries[parent.id] as OntimeGroup) : null,
);
queryClient.setQueryData<Rundown>(queryKey, newRundown);
@@ -199,7 +193,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
throw new Error('Rundown not initialised');
}
const newEntry: PatchWithId & ClientInsertOptions = { ...entry, id: generateId() };
const newEntry: PatchWithId & InsertOptions = { ...entry, id: generateId() };
// handle adding options that concern all event types
if (options?.after) {
@@ -211,8 +205,8 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
// ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) {
// last event id is the timing reference of the previous OntimeEvent in rundown
if (options?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const previousEvent = rundownData.entries[options?.lastEventId];
if (isOntimeEvent(previousEvent)) {
newEntry.timeStart = previousEvent.timeEnd;
@@ -290,7 +284,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId, options?: ClientInsertOptions) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
@@ -1063,7 +1057,7 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
/**
* Utility to create an optimistic entry for immediate cache insertion
*/
function createOptimisticEntry(payload: PatchWithId & ClientInsertOptions): OntimeEntry {
function createOptimisticEntry(payload: PatchWithId & InsertOptions): OntimeEntry {
const { after: _after, before: _before, ...entryData } = payload;
const id = entryData.id;
let parent: EntryId | null = null;
-11
View File
@@ -139,17 +139,6 @@ export const setEventPlayback = {
pause: () => sendSocket('pause', undefined),
};
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
playback: state.timer.playback,
phase: state.timer.phase,
addedTime: state.timer.addedTime,
secondaryTimer: state.timer.secondaryTimer,
current: state.timer.current,
expectedFinish: state.timer.expectedFinish,
startedAt: state.timer.startedAt,
isCountToEnd: state.eventNow?.countToEnd ?? false,
}));
export const useTimer = createSelector((state: RuntimeStore) => ({
...state.timer,
}));
@@ -1,25 +0,0 @@
import { describe, expect, it } from 'vitest';
import { removeFileExtension } from '../uploadUtils';
describe('removeFileExtension()', () => {
it('removes a trailing extension', () => {
expect(removeFileExtension('show.xlsx')).toBe('show');
});
it('only removes the last extension', () => {
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
});
it('returns the name unchanged when there is no extension', () => {
expect(removeFileExtension('rundown')).toBe('rundown');
});
it('does not treat a leading dot as an extension', () => {
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
});
it('handles an empty string', () => {
expect(removeFileExtension('')).toBe('');
});
});
+4 -9
View File
@@ -3,9 +3,7 @@ import {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
dayInMs,
formatFromMillis,
getExpectedEnd,
getExpectedStart,
} from 'ontime-utils';
@@ -31,11 +29,6 @@ export function nowInMillis(): number {
return elapsed;
}
export function normaliseWallClock(time: number): number {
const timeOfDay = time % dayInMs;
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
}
/**
* @description Resolves format from url and store
* @return {string|null} A format string like "hh:mm:ss a" or null
@@ -187,13 +180,15 @@ export function getExpectedTimesFromExtendedEvent(
...state,
},
);
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
const plannedEnd = event.timeStart + event.duration + event.delay;
return {
expectedStart,
timeToStart: expectedStart - state.clock,
expectedEnd,
expectedEnd: event.countToEnd
? Math.max(expectedStart + event.duration, plannedEnd)
: expectedStart + event.duration,
plannedEnd,
};
}
@@ -38,18 +38,6 @@ export function validateProjectFile(file: File) {
}
}
/**
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
*/
export function removeFileExtension(fileName: string): string {
const lastDot = fileName.lastIndexOf('.');
if (lastDot <= 0) {
return fileName;
}
return fileName.slice(0, lastDot);
}
export function isExcelFile(file: File | null) {
return file?.name.endsWith('.xlsx');
}
@@ -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'
/>
@@ -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}
/>
)}
@@ -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);
}
@@ -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,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,
});
});
});
@@ -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 '';
}
@@ -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,
};
}
@@ -25,13 +25,6 @@
margin-top: 1rem;
}
.finishActions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
}
.sourceGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1,6 +1,4 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
SpreadsheetWorksheetOptions,
@@ -8,7 +6,6 @@ import type {
import { getErrorMessage, ImportMap } from 'ontime-utils';
import { ChangeEvent, useCallback, useRef, useState } from 'react';
import { IoCloudOutline, IoDownloadOutline } from 'react-icons/io5';
import { useNavigate } from 'react-router';
import {
getWorksheetMetadata as getExcelWorksheetMetadata,
@@ -25,7 +22,7 @@ import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import useRundown from '../../../../../common/hooks-query/useRundown';
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
import * as Panel from '../../../panel-utils/PanelUtils';
import GSheetSetup from './GSheetSetup';
import SheetImportEditor from './sheet-import/SheetImportEditor';
@@ -38,7 +35,6 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
type ActiveSource =
| {
kind: 'excel';
fileName: string;
worksheetNames: string[];
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
closedByUser: boolean;
@@ -57,11 +53,9 @@ export default function SourcesPanel() {
const [error, setError] = useState('');
const [hasFile, setHasFile] = useState<'none' | 'loading' | 'done'>('none');
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
const [completedRundownTitle, setCompletedRundownTitle] = useState('');
const { data: currentRundown } = useRundown();
const { applyImport } = useSpreadsheetImport();
const navigate = useNavigate();
const { importRundown } = useSpreadsheetImport();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -80,7 +74,6 @@ export default function SourcesPanel() {
const worksheetOptions = await uploadExcel(fileToUpload);
setActiveSource({
kind: 'excel',
fileName: fileToUpload.name,
worksheetNames: worksheetOptions.worksheets,
initialWorksheetMetadata: worksheetOptions.metadata,
closedByUser: false,
@@ -104,7 +97,6 @@ export default function SourcesPanel() {
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle('');
};
const openGSheetFlow = () => {
@@ -127,41 +119,29 @@ export default function SourcesPanel() {
}
};
const handleFinished = (rundownTitle: string) => {
const handleFinished = () => {
setImportFlow('finished');
setHasFile('none');
setActiveSource(null);
setError('');
setCompletedRundownTitle(rundownTitle);
};
const handleApplyImport = async (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => {
if (mode === 'new') {
const title = newRundownTitle.trim() || preview.rundown.title;
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
handleFinished(title);
return;
}
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
if (!currentRundown) {
throw new Error('No current rundown loaded');
}
// override or merge into the current rundown; merge uses providedFields to know which columns
// the sheet supplied so matched events keep the rest (e.g. automations)
await applyImport({
mode,
targetRundownId: currentRundown.id,
rundown: preview.rundown,
customFields: preview.customFields,
providedFields,
});
handleFinished(currentRundown.title);
await importRundown(
{
[currentRundown.id]: {
...preview.rundown,
id: currentRundown.id,
title: currentRundown.title,
},
},
preview.customFields,
);
handleFinished();
};
const loadWorksheetMetadata = useCallback(
@@ -228,13 +208,6 @@ export default function SourcesPanel() {
if (activeSource.kind === 'excel') return 'excel';
return `gsheet:${activeSource.sheetId}`;
})();
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
// for Excel, or the document title for Google Sheets
const spreadsheetName = (() => {
if (!activeSource) return '';
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
return activeSource.title;
})();
return (
<Panel.Section>
@@ -294,20 +267,11 @@ export default function SourcesPanel() {
{showCompleted && (
<div className={style.finishSection}>
<span className={style.finishBadge}>Import complete</span>
<div className={style.finishTitle}>
Spreadsheet data applied to {completedRundownTitle || 'your rundown'}.
</div>
<div className={style.finishDescription}>
Review the imported rundown in the editor or start another import.
</div>
<div className={style.finishActions}>
<Button variant='primary' onClick={() => navigate('/editor')}>
Open editor
</Button>
<Button variant='subtle-white' onClick={resetFlow}>
Import another
</Button>
</div>
<div className={style.finishTitle}>Spreadsheet data applied.</div>
<div className={style.finishDescription}>You can close this flow or start another import.</div>
<Button variant='subtle-white' onClick={resetFlow}>
Reset flow
</Button>
</div>
)}
{isGSheetFlow && (
@@ -323,7 +287,6 @@ export default function SourcesPanel() {
bodyElements={
<SheetImportEditor
sourceKey={sourceKey ?? 'spreadsheet'}
defaultRundownName={spreadsheetName}
worksheetNames={activeSource?.worksheetNames ?? []}
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
loadMetadata={loadWorksheetMetadata}
@@ -1,55 +0,0 @@
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
import { useEffect, useState } from 'react';
import Button from '../../../../../../common/components/buttons/Button';
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
interface ApplyImportButtonProps {
preview: SpreadsheetPreviewResponse | null;
mode: RundownImportMode;
disabled: boolean;
loading: boolean;
onApply: () => void;
}
/**
* Apply action for the spreadsheet import.
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
* Requires a second click to confirm when applying would stop a running playback.
*/
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
const playback = usePlayback();
const loadedEventId = useSelectedEventId();
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
const willLoadedEventBeOverriden = !(
loadedEntry !== undefined &&
isOntimeEvent(loadedEntry) &&
isPlayableEvent(loadedEntry)
);
// applying stops playback when creating a new rundown, or when the playing event does not survive
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
// two-step confirmation before applying an import that stops playback
const [confirmStop, setConfirmStop] = useState(false);
useEffect(() => {
setConfirmStop(false);
}, [mode, preview]);
const handleClick = () => {
if (willStopPlayback && !confirmStop) {
setConfirmStop(true);
return;
}
onApply();
};
return (
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
</Button>
);
}
@@ -33,11 +33,6 @@
white-space: nowrap;
}
.importModeTrigger {
min-width: 12rem;
justify-content: space-between;
}
.addColumnTrigger {
justify-content: center;
white-space: nowrap;
@@ -92,10 +87,6 @@
font-weight: 600;
}
.toolbarWarning {
color: $orange-400;
}
.mappingPaneTitle {
align-self: center;
}
@@ -1,20 +1,10 @@
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { useMemo } from 'react';
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
import Button from '../../../../../../common/components/buttons/Button';
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
import Input from '../../../../../../common/components/input/input/Input';
import Select from '../../../../../../common/components/select/Select';
import * as Panel from '../../../../panel-utils/PanelUtils';
import ApplyImportButton from './ApplyImportButton';
import { isIdColumnMapped } from './importMapUtils';
import PreviewTable from './preview/PreviewTable';
import SheetImportMappingPane from './SheetImportMappingPane';
import { useSheetImportForm } from './useSheetImportForm';
@@ -23,46 +13,17 @@ import style from './SheetImportEditor.module.scss';
interface SheetImportEditorProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onCancel: () => void;
onExport?: (importMap: ImportMap) => Promise<void>;
}
const importModeOptions: Array<{
value: RundownImportMode;
label: string;
description: string;
}> = [
{
value: 'override',
label: 'Replace current rundown',
description: 'Spreadsheet data completely replaces current rundown',
},
{
value: 'merge',
label: 'Merge with current rundown',
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
},
{
value: 'new',
label: 'New rundown',
description: 'Create a new rundown to import the data into. Loads the new rundown',
},
];
export default function SheetImportEditor({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -88,16 +49,11 @@ export default function SheetImportEditor({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit,
handleExportSubmit,
handleApply,
} = useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -106,19 +62,6 @@ export default function SheetImportEditor({
onExport,
});
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
const importModeItems = useMemo<DropdownMenuOption[]>(
() =>
importModeOptions.map((option) => ({
type: 'item',
label: option.label,
description: option.description,
icon: importMode === option.value ? IoCheckmark : undefined,
onClick: () => setImportMode(option.value),
})),
[importMode, setImportMode],
);
return (
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
@@ -132,16 +75,7 @@ export default function SheetImportEditor({
}
/>
</label>
{toolbarStatus && (
<Panel.Description>
{toolbarStatus.entries === ''
? 'No import preview yet'
: `${toolbarStatus.entries} entries · ${toolbarStatus.groups} groups · ${toolbarStatus.milestones} milestones · ${toolbarStatus.start}${toolbarStatus.end} · ${toolbarStatus.duration}`}
{toolbarStatus.warnings > 0 && (
<span className={style.toolbarWarning}> · {toolbarStatus.warnings} warnings</span>
)}
</Panel.Description>
)}
{toolbarStatus && <Panel.Description>{toolbarStatus}</Panel.Description>}
</Panel.InlineElements>
<div className={style.editorBody}>
@@ -159,19 +93,13 @@ export default function SheetImportEditor({
<section className={style.previewPane}>
<div className={style.previewPaneHeader}>
<div className={style.previewPaneHeading}>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
<span className={style.previewPaneTitle}>Import preview</span>
</div>
<div className={style.tableShell}>
<PreviewTable
preview={state.preview}
columnLabels={columnLabels}
canRefresh={canPreview}
isLoadingMetadata={isLoadingMetadata}
isRefreshing={state.loading === 'preview'}
needsPreviewRefresh={state.needsPreviewRefresh}
onRefresh={handlePreviewSubmit}
worksheetHeaders={worksheetHeaders}
/>
</div>
@@ -179,63 +107,33 @@ export default function SheetImportEditor({
</div>
{displayError && <Panel.Error>{displayError}</Panel.Error>}
{importMode === 'merge' && !isIdColumnMapped(values) && (
<Panel.Description tone='warning'>
<IoWarningOutline /> No ID column mapped merge matches entries by ID, so it will behave like Replace. Export
your rundown to a spreadsheet first to keep its IDs.
</Panel.Description>
)}
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
<Panel.InlineElements wrap='wrap'>
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>Import mode</span>
<DropdownMenu
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
items={importModeItems}
>
{selectedImportMode.label}
<IoChevronDown />
</DropdownMenu>
</label>
{importMode === 'new' && (
<label className={style.worksheetControl}>
<span className={style.worksheetLabel}>New rundown name</span>
<Input
value={newRundownTitle}
onChange={(event) => setNewRundownTitle(event.target.value)}
placeholder={state.preview?.rundown.title || 'Imported rundown'}
aria-label='New rundown name'
/>
</label>
)}
</Panel.InlineElements>
<Panel.InlineElements wrap='wrap'>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
<Button onClick={onCancel} disabled={isBusy}>
Cancel
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
</Button>
{onExport && (
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
<IoArrowUpOutline />
Export
</Button>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<ApplyImportButton
preview={state.preview}
mode={importMode}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
onApply={handleApply}
/>
</Panel.InlineElements>
)}
<Button
variant={state.preview ? undefined : 'primary'}
onClick={handlePreviewSubmit}
disabled={!canPreview}
loading={state.loading === 'preview'}
>
<IoEye />
Preview import
</Button>
<Button
variant='primary'
onClick={handleApply}
disabled={!state.preview || isBusy}
loading={state.loading === 'apply'}
>
Apply import
</Button>
</Panel.InlineElements>
</Panel.Section>
);
@@ -1,21 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
builtInFieldDefs,
convertToImportMap,
createDefaultFormValues,
defaultImportMode,
getImportWarnings,
getPersistedImportMode,
getProvidedImportFields,
getResolvedCustomFields,
isIdColumnMapped,
persistImportMode,
} from '../importMapUtils';
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
describe('getImportWarnings()', () => {
it('warns when two mappings target the same spreadsheet column', () => {
@@ -134,64 +128,3 @@ describe('convertToImportMap()', () => {
});
});
});
describe('getProvidedImportFields()', () => {
it('reports the mapped built-in and custom fields the sheet supplies', () => {
const values = createDefaultFormValues();
values.builtIn[titleIndex] = { header: 'title', enabled: true };
values.builtIn[cueIndex] = { header: '', enabled: false };
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
const provided = getProvidedImportFields(convertToImportMap(values));
// enabled built-in mappings are reported as event fields, disabled ones are not
expect(provided.event).toContain('title');
expect(provided.event).not.toContain('cue');
// the id column is only used for matching, never overwritten
expect(provided.event).not.toContain('id');
// custom fields are reported symmetrically by their resolved Ontime name
expect(provided.custom).toStrictEqual(['FOH Monitor']);
});
});
describe('isIdColumnMapped()', () => {
it('is true when the ID field is enabled with a header', () => {
// the default form maps the ID column
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
});
it('is false when the ID field is disabled', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: 'id', enabled: false };
expect(isIdColumnMapped(values)).toBe(false);
});
it('is false when the ID field header is blank', () => {
const values = createDefaultFormValues();
values.builtIn[idIndex] = { header: ' ', enabled: true };
expect(isIdColumnMapped(values)).toBe(false);
});
});
describe('import mode persistence', () => {
const sourceKey = 'excel-test';
beforeEach(() => {
localStorage.clear();
});
it('defaults to override when nothing is persisted', () => {
expect(getPersistedImportMode(sourceKey)).toBe('override');
expect(defaultImportMode).toBe('override');
});
it('round-trips a persisted value', () => {
persistImportMode(sourceKey, 'merge');
expect(getPersistedImportMode(sourceKey)).toBe('merge');
});
it('falls back to the default when the persisted value is invalid', () => {
persistImportMode(sourceKey, 'nonsense' as never);
expect(getPersistedImportMode(sourceKey)).toBe('override');
});
});
@@ -1,4 +1,3 @@
import type { ImportedFields, RundownImportMode } from 'ontime-types';
import type { ImportMap } from 'ontime-utils';
import { makeStageKey } from '../../../../../../common/utils/localStorage';
@@ -50,16 +49,6 @@ export function createDefaultFormValues(): ImportFormValues {
};
}
/**
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
*/
export function isIdColumnMapped(values: ImportFormValues): boolean {
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
const field = values.builtIn[idIndex];
return Boolean(field?.enabled && field.header.trim());
}
function sanitiseOntimeCustomFieldLabel(importName: string): string {
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
const sanitised = importName
@@ -92,24 +81,6 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
});
}
/**
* Returns the fields the import map supplies the complete description of what the incoming data
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
* onto a matched event and keep everything else (e.g. automations) untouched.
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
* for matching, not overwritten.
*/
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
const event: string[] = [];
for (const [key, value] of Object.entries(importMap)) {
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
if (typeof value === 'string' && value.trim() !== '') {
event.push(key);
}
}
return { event, custom: Object.keys(importMap.custom) };
}
export function convertToImportMap(values: ImportFormValues): ImportMap {
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
(accumulator, { ontimeName, importName }) => {
@@ -159,12 +130,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
const storageKey = getImportMapKey(sourceKey);
try {
const persistedData = localStorage.getItem(storageKey);
if (!persistedData) {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return { values: createDefaultFormValues(), isPersisted: false };
}
const parsed: unknown = JSON.parse(persistedData);
const parsed: unknown = JSON.parse(raw);
if (isPersistedFormValues(parsed)) {
return { values: parsed, isPersisted: true };
}
@@ -179,32 +150,6 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
}
}
/**
* The import mode (new / merge / override) is persisted separately from the field mapping
* so the mapping schema guard stays untouched.
*/
/** Default import mode: replace matched elements in the current rundown */
export const defaultImportMode: RundownImportMode = 'override';
function getImportModeKey(sourceKey: string) {
return makeStageKey(`import-mode:${sourceKey}`);
}
/** Persists the import mode for a given source */
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
localStorage.setItem(getImportModeKey(sourceKey), mode);
}
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
return persisted;
}
return defaultImportMode;
}
/**
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
*/
@@ -1,35 +1,27 @@
.emptyState {
padding: 3rem 1.5rem;
height: 100%;
min-height: 16rem;
display: grid;
place-content: center;
gap: 0.35rem;
padding: 1.5rem;
text-align: center;
}
.emptyMessage {
width: min(30rem, 100%);
margin-inline: auto;
}
.emptyTitle {
margin-bottom: 0.25rem;
color: rgba($gray-200, 0.72);
font-size: calc(1rem + 2px);
font-weight: 400;
color: $ui-white;
font-size: 1rem;
font-weight: 600;
}
.emptyBody {
color: rgba($gray-200, 0.55);
font-size: calc(1rem - 3px);
line-height: 1.5;
}
.emptyAction {
margin: 1rem auto 0;
color: $gray-400;
font-size: 0.95rem;
}
.table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
color: $ui-white;
border-collapse: collapse;
font-size: calc(1rem - 2px);
text-align: left;
table-layout: auto;
@@ -42,101 +34,31 @@
}
th {
color: $gray-300;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
vertical-align: bottom;
font-weight: 400;
color: $gray-400;
text-transform: capitalize;
vertical-align: top;
white-space: normal;
}
th,
td {
box-sizing: border-box;
padding: 0.5rem;
min-width: 8rem;
max-width: 20rem;
padding: 0.55rem 0.65rem;
border-bottom: 1px solid $white-10;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
vertical-align: top;
}
tbody tr {
--entry-colour: transparent;
background-color: color-mix(in srgb, $gray-1300 96%, var(--entry-colour) 4%);
box-shadow: inset 3px 0 var(--entry-colour);
tr:nth-child(even) {
background-color: $white-1;
}
td[data-empty='true'] {
color: $gray-600;
}
}
.rowNumber,
.rowType {
position: sticky;
z-index: 1;
background-color: inherit;
}
.rowNumber {
left: 0;
width: 3.25rem;
min-width: 3.25rem !important;
color: $gray-400;
text-align: right;
width: 4.5rem;
min-width: 4.5rem;
}
.rowType {
left: 3.25rem;
width: 6.25rem;
min-width: 6.25rem !important;
color: $gray-400;
font-size: 0.8rem;
box-shadow: 1px 0 $white-10;
}
thead .rowNumber,
thead .rowType {
z-index: 2;
background-color: $gray-1350;
}
.numericCell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.multilineCell {
max-width: 30rem !important;
overflow-wrap: anywhere;
text-overflow: clip !important;
white-space: pre-wrap !important;
}
.eventRow {
.rowNumber {
font-variant-numeric: tabular-nums;
}
}
.groupRow {
background-color: color-mix(in srgb, $gray-1300 88%, var(--entry-colour) 12%) !important;
box-shadow: inset 4px 0 var(--entry-colour) !important;
font-weight: 600;
td {
min-height: 3.25rem;
border-top: 0.75rem solid $gray-1350;
}
}
.milestoneRow {
background-color: color-mix(in srgb, $gray-1300 93%, var(--entry-colour) 7%) !important;
box-shadow: inset 3px 0 var(--entry-colour) !important;
color: $gray-300;
font-style: italic;
width: 7rem;
min-width: 7rem;
}
@@ -1,11 +1,7 @@
import type { CustomField, CustomFieldKey, SpreadsheetPreviewResponse } from 'ontime-types';
import { isOntimeDelay, isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import type { CSSProperties } from 'react';
import { isOntimeEvent, isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { useMemo } from 'react';
import Button from '../../../../../../../common/components/buttons/Button';
import Tag from '../../../../../../../common/components/tag/Tag';
import { getRundownMetadata } from '../../../../../../../common/utils/rundownMetadata';
import { getCellValue } from './previewTableUtils';
import style from './PreviewTable.module.scss';
@@ -13,85 +9,14 @@ import style from './PreviewTable.module.scss';
interface PreviewTableProps {
preview: SpreadsheetPreviewResponse | null;
columnLabels: string[];
canRefresh: boolean;
isLoadingMetadata: boolean;
isRefreshing: boolean;
needsPreviewRefresh: boolean;
onRefresh: () => void;
worksheetHeaders: string[];
}
const priorityColumns = ['Title', 'Cue', 'Start', 'End', 'Duration'];
const numericColumns = new Set(['Start', 'End', 'Duration', 'Time warning', 'Time danger']);
const transparentColour = 'transparent';
type PreviewEntry = SpreadsheetPreviewResponse['rundown']['entries'][string];
function getEntryDisplay(entry: PreviewEntry, groupColour?: string) {
if (isOntimeGroup(entry)) {
return {
rowClassName: style.groupRow,
entryColour: entry.colour,
entryType: 'Group',
};
}
const entryColour = groupColour ?? transparentColour;
if (isOntimeMilestone(entry)) {
return {
rowClassName: style.milestoneRow,
entryColour,
entryType: 'Milestone',
};
}
if (isOntimeDelay(entry)) {
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Delay',
};
}
return {
rowClassName: style.eventRow,
entryColour,
entryType: 'Event',
};
}
function getCellClassName(label: string, value: string) {
if (value.includes('\n')) {
return style.multilineCell;
}
if (numericColumns.has(label)) {
return style.numericCell;
}
return undefined;
}
function getDisplayColumns(columnLabels: string[]) {
return [...columnLabels].sort((left, right) => {
const leftPriority = priorityColumns.indexOf(left);
const rightPriority = priorityColumns.indexOf(right);
return (
(leftPriority === -1 ? priorityColumns.length : leftPriority) -
(rightPriority === -1 ? priorityColumns.length : rightPriority)
);
});
}
export default function PreviewTable({
preview,
columnLabels,
canRefresh,
isLoadingMetadata,
isRefreshing,
needsPreviewRefresh,
onRefresh,
worksheetHeaders,
}: PreviewTableProps) {
const customFieldKeyByLabel = useMemo(() => {
@@ -99,57 +24,33 @@ export default function PreviewTable({
return new Map(Object.entries(preview.customFields).map(([fieldId, field]) => [field.label, fieldId]));
}, [preview]);
const displayColumns = useMemo(() => getDisplayColumns(columnLabels), [columnLabels]);
const previewMetadata = useMemo(() => {
if (!preview) return null;
return getRundownMetadata(preview.rundown, null);
}, [preview]);
if (!preview) {
let emptyTitle = 'Preview not generated';
let emptyContent = 'Select the fields you want to import, then click Preview import.';
if (isLoadingMetadata) {
emptyTitle = 'Loading worksheet';
emptyContent = 'Loading worksheet metadata...';
} else if (worksheetHeaders.length === 0) {
emptyTitle = 'No headers found';
emptyContent =
'No column headers detected in this worksheet. Try a different worksheet or ensure the first row contains column headers.';
} else if (needsPreviewRefresh) {
emptyTitle = 'Preview needs updating';
emptyContent = 'Your column mapping changed. Preview the import again to update this table.';
}
return (
<div className={style.emptyState}>
<div className={style.emptyMessage}>
<div className={style.emptyTitle}>{emptyTitle}</div>
<div className={style.emptyBody}>{emptyContent}</div>
{needsPreviewRefresh && (
<Button
className={style.emptyAction}
variant='primary'
onClick={onRefresh}
disabled={!canRefresh}
loading={isRefreshing}
>
Refresh preview
</Button>
)}
</div>
<div className={style.emptyTitle}>Preview not generated</div>
<div className={style.emptyBody}>{emptyContent}</div>
</div>
);
}
let eventIndex = 0;
return (
<table className={style.table}>
<thead>
<tr>
<th className={style.rowNumber}>#</th>
<th className={style.rowType}>Type</th>
{displayColumns.map((label, index) => (
{columnLabels.map((label, index) => (
<th key={`${label}-${index}`}>{label}</th>
))}
</tr>
@@ -158,29 +59,16 @@ export default function PreviewTable({
{preview.rundown.flatOrder.map((entryId) => {
const entry = preview.rundown.entries[entryId];
const isEvent = isOntimeEvent(entry);
const entryMetadata = previewMetadata?.[entryId];
const { rowClassName, entryColour, entryType } = getEntryDisplay(entry, entryMetadata?.groupColour);
if (isEvent) eventIndex++;
const hasType = isEvent || isOntimeGroup(entry) || isOntimeMilestone(entry);
return (
<tr key={entryId} className={rowClassName} style={{ '--entry-colour': entryColour } as CSSProperties}>
<td className={style.rowNumber}>{isEvent ? entryMetadata?.eventIndex : ''}</td>
<td className={style.rowType}>
<Tag>{entryType}</Tag>
</td>
{displayColumns.map((label, colIndex) => {
const value = getCellValue(label, entry, customFieldKeyByLabel);
const cellClassName = getCellClassName(label, value);
return (
<td
key={`${entryId}-${colIndex}`}
className={cellClassName}
data-empty={value === ''}
title={value || undefined}
>
{value}
</td>
);
})}
<tr key={entryId}>
<td className={style.rowNumber}>{isEvent ? eventIndex : ''}</td>
<td className={style.rowType}>{hasType ? entry.type : ''}</td>
{columnLabels.map((label, colIndex) => (
<td key={`${entryId}-${colIndex}`}>{getCellValue(label, entry, customFieldKeyByLabel)}</td>
))}
</tr>
);
})}
@@ -1,13 +1,7 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ImportedFields,
RundownImportMode,
SpreadsheetPreviewResponse,
SpreadsheetWorksheetMetadata,
} from 'ontime-types';
import { isOntimeGroup, isOntimeMilestone } from 'ontime-types';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { maybeAxiosError } from '../../../../../../common/api/utils';
@@ -17,11 +11,8 @@ import {
builtInFieldDefs,
convertToImportMap,
getImportWarnings,
getPersistedImportMode,
getPersistedImportState,
getProvidedImportFields,
getResolvedCustomFields,
persistImportMode,
persistImportState,
} from './importMapUtils';
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
@@ -33,7 +24,7 @@ type ImportAction =
| { type: 'previewSuccess'; preview: SpreadsheetPreviewResponse }
| { type: 'applySuccess' }
| { type: 'exportSuccess' }
| { type: 'clearPreview'; error?: string; needsRefresh?: boolean }
| { type: 'clearPreview'; error?: string }
| { type: 'failure'; error: string }
| { type: 'reset' };
@@ -41,14 +32,12 @@ type ImportState = {
loading: '' | 'preview' | 'apply' | 'export';
error: string;
preview: SpreadsheetPreviewResponse | null;
needsPreviewRefresh: boolean;
};
const initialImportState: ImportState = {
loading: '',
error: '',
preview: null,
needsPreviewRefresh: false,
};
function importReducer(state: ImportState, action: ImportAction): ImportState {
@@ -60,15 +49,15 @@ function importReducer(state: ImportState, action: ImportAction): ImportState {
case 'startExport':
return { ...state, loading: 'export', error: '' };
case 'previewSuccess':
return { loading: '', error: '', preview: action.preview, needsPreviewRefresh: false };
return { loading: '', error: '', preview: action.preview };
case 'applySuccess':
case 'exportSuccess':
return { ...state, loading: '' };
case 'clearPreview':
return { ...state, error: action.error ?? '', preview: null, needsPreviewRefresh: action.needsRefresh ?? false };
return { ...state, error: action.error ?? '', preview: null };
case 'failure': {
if (state.loading === 'preview') {
return { loading: '', error: action.error, preview: null, needsPreviewRefresh: false };
return { loading: '', error: action.error, preview: null };
}
return { ...state, loading: '', error: action.error };
}
@@ -111,23 +100,16 @@ function buildColumnLabels(values: ImportFormValues): string[] {
interface UseSheetImportFormProps {
sourceKey: string;
defaultRundownName: string;
worksheetNames: string[];
initialMetadata: SpreadsheetWorksheetMetadata | null;
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
onApply: (
preview: SpreadsheetPreviewResponse,
mode: RundownImportMode,
newRundownTitle: string,
providedFields: ImportedFields,
) => Promise<void>;
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
}
export function useSheetImportForm({
sourceKey,
defaultRundownName,
worksheetNames,
initialMetadata,
loadMetadata,
@@ -188,8 +170,6 @@ export function useSheetImportForm({
const columnLabels = buildColumnLabels(values);
const [state, dispatch] = useReducer(importReducer, initialImportState);
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
const warnings = getImportWarnings(values, headers);
const warningCount = Object.values(warnings).filter(Boolean).length;
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
@@ -202,12 +182,6 @@ export function useSheetImportForm({
dispatch({ type: 'reset' });
}, [initialFormValues, reset]);
// Update import mode and rundown name if the source changes
useEffect(() => {
setImportMode(getPersistedImportMode(sourceKey));
setNewRundownTitle(defaultRundownName);
}, [sourceKey, defaultRundownName]);
// Keep the worksheet selection valid if the available worksheets change underneath the form.
useEffect(() => {
if (worksheetNames.length === 0) return;
@@ -224,7 +198,7 @@ export function useSheetImportForm({
const sub = watch(() => {
if (!previewRef.current) return;
previewRef.current = null;
dispatch({ type: 'clearPreview', needsRefresh: true });
dispatch({ type: 'clearPreview' });
});
return () => sub.unsubscribe();
}, [watch]);
@@ -267,15 +241,13 @@ export function useSheetImportForm({
try {
dispatch({ type: 'startApply' });
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
await onApply(state.preview, importMode, newRundownTitle, providedFields);
await onApply(state.preview);
persistImportState(sourceKey, getValues());
persistImportMode(sourceKey, importMode);
dispatch({ type: 'applySuccess' });
} catch (error) {
dispatch({ type: 'failure', error: maybeAxiosError(error) });
}
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
}, [getValues, onApply, sourceKey, state.preview]);
const handleExport = useCallback(
async (formValues: ImportFormValues) => {
@@ -300,37 +272,15 @@ export function useSheetImportForm({
}, [append]);
const toolbarStatus = (() => {
const warningText = warningCount > 0 ? ` | warnings: ${warningCount}` : '';
if (!state.preview) {
return {
entries: '',
groups: '',
milestones: '',
start: '',
end: '',
duration: '',
warnings: warningCount,
};
return `entries: | start: | end: | duration: ${warningText}`;
}
const { entries, flatOrder } = state.preview.rundown;
const { flatOrder } = state.preview.rundown;
const { start, end, duration } = state.preview.summary;
let groups = 0;
let milestones = 0;
for (const entryId of flatOrder) {
const entry = entries[entryId];
if (isOntimeGroup(entry)) groups++;
else if (isOntimeMilestone(entry)) milestones++;
}
return {
entries: flatOrder.length,
groups,
milestones,
start: removeTrailingZero(millisToString(start)),
end: removeTrailingZero(millisToString(end)),
duration: formatDuration(duration),
warnings: warningCount,
};
return `entries: ${flatOrder.length} | start: ${millisToString(start)} | end: ${millisToString(end)} | duration: ${formatDuration(duration)}${warningText}`;
})();
return {
@@ -350,10 +300,6 @@ export function useSheetImportForm({
isBusy,
canPreview,
displayError,
importMode,
setImportMode,
newRundownTitle,
setNewRundownTitle,
handlePreviewSubmit: handleSubmit(handlePreview),
handleExportSubmit: handleSubmit(handleExport),
handleApply,
@@ -1,17 +1,30 @@
import { RundownImportPayload } from 'ontime-types';
import { useQueryClient } from '@tanstack/react-query';
import { CustomFields, ProjectRundowns } from 'ontime-types';
import { useCallback } from 'react';
import { importRundownWithOptions } from '../../../../../common/api/rundown';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
import { patchData } from '../../../../../common/api/db';
export default function useSpreadsheetImport() {
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
const applyImport = useCallback(async (payload: RundownImportPayload) => {
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
// through that single path rather than racing it with an optimistic write from here
await importRundownWithOptions(payload);
}, []);
const queryClient = useQueryClient();
/** applies rundown and customFields to current project */
const importRundown = useCallback(
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need
// it to be normalised
await queryClient.invalidateQueries({
queryKey: RUNDOWN,
});
await queryClient.invalidateQueries({
queryKey: CUSTOM_FIELDS,
});
},
[queryClient],
);
return {
applyImport,
importRundown,
};
}
@@ -73,10 +73,6 @@
margin-right: 0.25rem;
}
.tagOvertime {
color: $playback-over;
}
.time {
color: $section-white;
font-size: $text-body-size;
@@ -1,13 +1,12 @@
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { dayInMs, millisToString } from 'ontime-utils';
import { PropsWithChildren } from 'react';
import AppLink from '../../../../common/components/link/app-link/AppLink';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import useReport from '../../../../common/hooks-query/useReport';
import { useTimerProgress } from '../../../../common/hooks/useSocket';
import { cx } from '../../../../common/utils/styleUtils';
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
import { useTimer } from '../../../../common/hooks/useSocket';
import { formatDuration } from '../../../../common/utils/time';
import TimerDisplay from '../timer-display/TimerDisplay';
import style from './PlaybackTimer.module.scss';
@@ -25,14 +24,15 @@ function resolveAddedTimeLabel(addedTime: number) {
}
export default function PlaybackTimer({ children }: PropsWithChildren) {
'use memo';
const timer = useTimerProgress();
const timer = useTimer();
const isRolling = timer.playback === Playback.Roll;
const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
return (
@@ -51,13 +51,7 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
{isWaiting ? (
<span className={style.rolltag}>Roll: Countdown to start</span>
) : (
<RunningStatus
startedAt={timer.startedAt}
expectedFinish={timer.expectedFinish}
isStopped={timer.playback === Playback.Stop}
isCountToEnd={timer.isCountToEnd}
isOvertime={isOvertime}
/>
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
)}
</div>
{children}
@@ -68,18 +62,16 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
interface RunningStatusProps {
startedAt: MaybeNumber;
expectedFinish: MaybeNumber;
isStopped: boolean;
isCountToEnd: boolean;
isOvertime: boolean;
playback: Playback;
}
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
if (isStopped) {
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
if (playback === Playback.Stop) {
return <StoppedStatus />;
}
const started = millisToString(startedAt);
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
const finish = millisToString(finishedMs);
return (
<>
@@ -88,9 +80,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
<span className={style.time}>{started}</span>
</span>
<span className={style.finish}>
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
</span>
<span className={style.tag}>Expect end</span>
<span className={style.time}>{finish}</span>
</span>
</>
@@ -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>;
}
+1 -20
View File
@@ -105,29 +105,12 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
handleCollapseGroup,
});
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
const jumpToCurrent = useCallback(() => {
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
if (!targetId) {
return;
}
// Open parent group if the target is inside a collapsed group
const entry = entries[targetId];
if (entry && 'parent' in entry) {
expandGroup(entry.parent);
}
scrollToEntry(targetId);
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
// Keyboard shortcuts
useRundownKeyboard({
cursor,
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
});
// DND handlers
@@ -339,9 +322,7 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
increaseViewportBy={{ top: 200, bottom: 400 }}
style={{ height: '100%' }}
components={{
Header: isEditMode
? () => <QuickAddButtons previousEventId={null} nextEventId={order[0]} parentGroup={null} />
: undefined,
Header: isEditMode ? () => <QuickAddButtons previousEventId={null} parentGroup={null} /> : undefined,
Footer: () => (
<>
{isEditMode && (
@@ -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,127 +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='Jump to current entry'>
<Combo keys={[deviceAlt, 'L']} />
</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>;
}
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
import Tooltip from '../../../../common/components/tooltip/Tooltip';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
import { formatTime } from '../../../../common/utils/time';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EntryEditor.module.scss';
@@ -68,10 +68,8 @@ function EventEditorTimes({
};
const hasDelay = delay !== 0;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedEnd = normaliseWallClock(timeEnd + delay);
const delayLabel = hasDelay
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)}${formatTime(delayedEnd)}`
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)}${formatTime(timeEnd + delay)}`
: '';
return (
@@ -88,6 +86,7 @@ function EventEditorTimes({
timeStrategy={timeStrategy}
linkStart={linkStart}
delay={delay}
countToEnd={countToEnd}
showLabels
/>
</div>
@@ -11,17 +11,13 @@ import style from './QuickAddButtons.module.scss';
interface QuickAddButtonsProps {
previousEventId: MaybeString;
nextEventId?: MaybeString;
parentGroup: MaybeString;
backgroundColor?: string;
}
export default memo(QuickAddButtons);
function QuickAddButtons({ previousEventId, nextEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
function QuickAddButtons({ previousEventId, parentGroup, backgroundColor }: QuickAddButtonsProps) {
const { addEntry } = useEntryActionsContext();
const appendOptions = previousEventId ? { after: previousEventId, lastEventId: previousEventId } : undefined;
const prependOptions = nextEventId ? { before: nextEventId } : undefined;
const insertionOptions = appendOptions ?? prependOptions;
const addEvent = () => {
addEntry(
@@ -29,23 +25,44 @@ function QuickAddButtons({ previousEventId, nextEventId, parentGroup, background
type: SupportedEntry.Event,
parent: parentGroup,
},
insertionOptions,
{
after: previousEventId,
lastEventId: previousEventId,
},
);
};
const addDelay = () => {
addEntry({ type: SupportedEntry.Delay, parent: parentGroup }, insertionOptions);
addEntry(
{ type: SupportedEntry.Delay, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addMilestone = () => {
addEntry({ type: SupportedEntry.Milestone, parent: parentGroup }, insertionOptions);
addEntry(
{ type: SupportedEntry.Milestone, parent: parentGroup },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
const addGroup = () => {
if (parentGroup !== null) {
return;
}
addEntry({ type: SupportedEntry.Group }, insertionOptions);
addEntry(
{ type: SupportedEntry.Group },
{
lastEventId: previousEventId,
after: previousEventId,
},
);
};
/**
@@ -96,7 +96,7 @@ export function useRundownCommands({
addEntry(patch, {
after: id && !above ? id : undefined,
before: id && above ? id : undefined,
lastEventId: id && !above ? id : undefined,
lastEventId: !above && id ? id : undefined,
});
},
[addEntry],
@@ -19,7 +19,6 @@ interface UseRundownKeyboardOptions {
};
clearSelectedEvents: () => void;
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
jumpToCurrent: () => void;
}
/**
@@ -41,7 +40,6 @@ export function useRundownKeyboard({
commands,
clearSelectedEvents,
setEntryCopyId,
jumpToCurrent,
}: UseRundownKeyboardOptions) {
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
@@ -129,8 +127,6 @@ export function useRundownKeyboard({
{ preventDefault: true, usePhysicalKeys: true },
],
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
[
'alt + mod + ArrowDown',
() => commands.moveEntry(cursor, 'down'),
@@ -7,7 +7,6 @@ $skip-opacity: 0.2;
background-color: $block-bg;
margin-block: 0.25rem;
overflow: initial;
position: relative;
display: grid;
grid-template-areas:
@@ -17,7 +16,7 @@ $skip-opacity: 0.2;
'binder pb-actions estatus estatus'
'binder ... ... ...';
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
align-items: center;
padding-right: $block-clearance;
@@ -137,7 +136,6 @@ $skip-opacity: 0.2;
align-items: center;
gap: $block-clearance;
height: 100%;
min-width: 0;
}
.eventTimers.editMode:hover {
@@ -157,7 +155,7 @@ $skip-opacity: 0.2;
align-items: center;
justify-content: space-between;
.warningMeta {
.nextTag {
font-size: 1rem;
color: $orange-500;
letter-spacing: 0.03px;
@@ -233,10 +231,6 @@ $skip-opacity: 0.2;
color: var(--status-color-active-override, $active-indicator);
}
.statusIcon.countToEndStatus {
color: $orange-400;
}
.statusIcon.warning {
color: $orange-500;
}
@@ -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);
@@ -115,12 +115,12 @@ function RundownEventInner({
delay={delay}
timeStrategy={timeStrategy}
linkStart={linkStart}
countToEnd={countToEnd}
/>
</div>
<div className={style.titleSection}>
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
{isNext && <span className={style.nextTag}>UP NEXT</span>}
</div>
<EventBlockPlayback
eventId={eventId}
@@ -157,13 +157,7 @@ function RundownEventInner({
<EndActionIcon action={endAction} className={style.statusIcon} />
</Tooltip>
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
<LuArrowDownToLine
className={cx([
style.statusIcon,
countToEnd ? style.active : style.disabled,
countToEnd && style.countToEndStatus,
])}
/>
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
</Tooltip>
<Tooltip text={automationTooltip} render={<span />}>
<IoFlash className={automationIconClasses} />
@@ -1,33 +1,10 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { formatDelay } from '../rundownEvent.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
const timeStart = 60000; // 1 min
const delay = 60000; // 1 min
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 00:02');
});
it('wraps negative delayed starts under midnight', () => {
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:59');
});
it('wraps later-day negative delays using delay as the source of truth', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 23:30');
});
it('displays positive delays as wall-clock time', () => {
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
const result = formatDelay(timeStart, delay);
expect(result).toEqual('New start 02:30');
});
});
@@ -3,5 +3,7 @@
width: 0;
border-radius: 1px 0 0 1px;
transition: 1s linear;
transition-property: width;
background-color: $gray-200;
}
@@ -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}%` }} />;
}
@@ -1,16 +1,15 @@
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
import { formatDuration } from '../../../common/utils/time';
export function formatDelay(timeStart: number, delay: number): string | undefined {
if (!delay) return;
const delayedStart = normaliseWallClock(timeStart + delay);
const delayedStart = Math.max(0, timeStart + delay);
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
@@ -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]),
},
]);
@@ -4,15 +4,10 @@
.timerNote {
width: 1.25em;
flex: 0 0 1.25em;
color: $blue-500;
font-size: 1.5em;
}
.timerNotePlaceholder {
visibility: hidden;
}
.inactive {
color: $muted-gray;
}
@@ -14,6 +14,7 @@ import style from './TimeInputFlow.module.scss';
interface TimeInputFlowProps {
eventId: string;
countToEnd: boolean;
timeStart: number;
timeEnd: number;
duration: number;
@@ -21,12 +22,12 @@ interface TimeInputFlowProps {
linkStart: boolean;
delay: number;
showLabels?: boolean;
showWarnings?: boolean;
}
export default memo(TimeInputFlow);
function TimeInputFlow({
eventId,
countToEnd,
timeStart,
timeEnd,
duration,
@@ -34,7 +35,6 @@ function TimeInputFlow({
linkStart,
delay,
showLabels,
showWarnings = true,
}: TimeInputFlowProps) {
const { updateEntry, updateTimer } = useEntryActionsContext();
@@ -56,6 +56,10 @@ function TimeInputFlow({
warnings.push('Over midnight');
}
if (countToEnd) {
warnings.push('Count to End');
}
const hasDelay = delay !== 0;
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
@@ -132,19 +136,11 @@ function TimeInputFlow({
</TimeInputGroup>
</div>
{showWarnings &&
(warnings.length > 0 ? (
<Tooltip
text={warnings.join(' - ')}
className={style.timerNote}
data-testid='event-warning'
render={<span />}
>
<IoAlertCircleOutline />
</Tooltip>
) : (
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
))}
{warnings.length > 0 && (
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
<IoAlertCircleOutline />
</Tooltip>
)}
</>
);
}
@@ -153,7 +153,7 @@ type ScheduleTimeProps = {
//TODO: consider relative mode
export function ScheduleTime(props: ScheduleTimeProps) {
const { event, showExpected } = props;
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
const { timeStart, duration, delay, expectedStart, countToEnd } = event;
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
@@ -164,6 +164,7 @@ export function ScheduleTime(props: ScheduleTimeProps) {
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
const plannedEnd = plannedStart + duration + delay;
const expectedEnd = countToEnd ? Math.max(expectedStart + duration, plannedEnd) : expectedStart + duration;
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
return (
@@ -1,15 +1,17 @@
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { getExpectedStart } from 'ontime-utils';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import useReport from '../../common/hooks-query/useReport';
import { useFadeOutOnInactivity } from '../../common/hooks/useFadeOutOnInactivity';
import { useExpectedStartData } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { cx } from '../../common/utils/styleUtils';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import { getPropertyValue } from '../common/viewUtils';
import { useCountdownOptions } from './countdown.options';
import { CountdownEvent, CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { CountdownTarget, useSubscriptionDisplayData } from './countdown.utils';
import { ScheduleTime } from './CountdownSubscriptions';
import './SingleEventCountdown.scss';
@@ -36,10 +38,8 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
mode,
});
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt };
const titleTmp = getPropertyValue(subscribedEvent, mainSource ?? 'title');
const title = titleTmp?.length ? titleTmp : ' '; // insert utf-8 empty space to avoid the line collapsing;
// while a group is live, surface the running event's title as the secondary line
@@ -64,7 +64,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
}
interface SubscriptionStatusProps {
event: CountdownEvent;
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
}
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
@@ -12,7 +12,7 @@ import {
isOntimeGroup,
isPlayableEvent,
} from 'ontime-types';
import { MILLIS_PER_MINUTE, getExpectedEnd, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { MILLIS_PER_MINUTE, getExpectedStart, millisToString, removeLeadingZero } from 'ontime-utils';
import { useCountdownSocket } from '../../common/hooks/useSocket';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
@@ -197,7 +197,7 @@ export type CountdownTarget = ExtendedEntry<OntimeEvent> & {
liveEntry?: ExtendedEntry<OntimeEvent> | null; // the running child while a group is live
};
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber; expectedEnd: number };
export type CountdownEvent = CountdownTarget & { expectedStart: number; endedAt: MaybeNumber };
/**
* Resolves a subscription (event or group) into an event-shaped countdown target.
@@ -271,7 +271,6 @@ export function extendEventData(
offset,
mode,
});
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
return { ...event, expectedStart, endedAt, expectedEnd };
return { ...event, expectedStart, endedAt };
}
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
{
type: 'item',
label: 'Planning',
description: 'Edit focused list with planning stats',
description: 'Edit-focused list with planning stats',
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
},
@@ -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;
}
}
+2 -20
View File
@@ -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>
}
/>
@@ -28,9 +28,9 @@
.timer-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
justify-self: center;
align-self: center;
width: 100%;
overflow: hidden;
@@ -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} />
);
+3 -3
View File
@@ -82,9 +82,9 @@
.timer-container {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-content: center;
justify-self: center;
align-self: center;
width: 100%;
overflow: hidden;
+1 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.11.0",
"version": "4.10.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -125,13 +125,6 @@
"**/*",
"*{.ts}"
]
},
{
"from": "../server/src/html/",
"to": "extraResources/html/",
"filter": [
"**/*"
]
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.11.0",
"version": "4.10.0",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
+9 -1
View File
@@ -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/*"]
}
]
}
}
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.11.0",
"version": "4.10.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -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;
}
@@ -432,40 +432,6 @@ describe('processRundown()', () => {
expect(initResult.totalDuration).toBe(500 - 100);
});
it('skipped events do not advance day offsets or affect gaps', () => {
const rundown = makeRundown({
order: ['1', 'skipped', '2'],
entries: {
'1': makeOntimeEvent({
id: '1',
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
skipped: makeOntimeEvent({
id: 'skipped',
skip: true,
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
}),
'2': makeOntimeEvent({
id: '2',
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
}),
},
});
const initResult = processRundown(rundown, {});
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
});
it('calculates total duration across days with gap', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
@@ -714,17 +680,17 @@ describe('processRundown()', () => {
});
describe('rundownMutation.add()', () => {
test('adds an event to an empty rundown', () => {
test('adds an event an empty rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({});
rundownMutation.add(rundown, mockEvent, null, null, null);
rundownMutation.add(rundown, mockEvent, null, null);
expect(rundown.order.length).toBe(1);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the end if no afterId is given', () => {
test('adds an event at the top if no afterId is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1'],
@@ -734,28 +700,28 @@ describe('rundownMutation.add()', () => {
},
});
rundownMutation.add(rundown, mockEvent, null, null, null);
rundownMutation.add(rundown, mockEvent, null, null);
expect(rundown.order).toStrictEqual(['1', 'mock']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock']);
expect(rundown.order).toStrictEqual(['mock', '1']);
expect(rundown.flatOrder).toStrictEqual(['mock', '1']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the end of the group if parent is provided but no after', () => {
test('adds an event at the top of the group if no after is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1': makeOntimeGroup({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, null, null);
rundownMutation.add(rundown, mockEvent, null, rundown.entries['1'] as OntimeGroup);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
expect(rundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
expect(rundown.entries['mock']).toMatchObject(mockEvent);
});
@@ -765,12 +731,12 @@ describe('rundownMutation.add()', () => {
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeGroup({ id: '1', entries: ['1a'] }),
'1': makeOntimeGroup({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
rundownMutation.add(rundown, mockEvent, rundown.entries['1'] as OntimeGroup, '1a', null);
rundownMutation.add(rundown, mockEvent, '1a', rundown.entries['1'] as OntimeGroup);
expect(rundown.order).toStrictEqual(['1']);
expect(rundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
@@ -1839,49 +1805,6 @@ describe('rundownMutation.clone()', () => {
parent: null,
});
});
it('clones an event and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
it('clones an event and prepends it with before true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['2'], { before: true });
expect(testRundown.order).toStrictEqual([newEntry.id, '1', '2']);
});
it('clones a group and appends it with after true', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeGroup({ id: '1', title: 'top', entries: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { after: true });
expect(testRundown.order).toStrictEqual(['1', '2', newEntry.id]);
});
});
describe('rundownMutation.group()', () => {
@@ -1,18 +1,8 @@
import {
EndAction,
OntimeEvent,
OntimeGroup,
OntimeMilestone,
TimeStrategy,
TimerLifeCycle,
TimerType,
Trigger,
} from 'ontime-types';
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
import { assertType } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
import { parseRundown } from '../rundown.parser.js';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import {
calculateDayOffset,
deleteById,
@@ -20,8 +10,6 @@ import {
getIntegerAndFraction,
hasChanges,
makeDeepClone,
mergeRundownPreservingFields,
isLoadedPlayable,
} from '../rundown.utils.js';
describe('test event validator', () => {
@@ -299,314 +287,3 @@ describe('getIntegerAndFraction()', () => {
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
});
});
/**
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
* any field it does not provide (e.g. automations) is kept from the existing event.
*/
describe('mergeRundownPreservingFields()', () => {
const automation: Trigger = {
id: 'trigger-onair',
title: 'Go on air',
trigger: TimerLifeCycle.onStart,
automationId: 'automation-onair',
};
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
title: 'Main show',
revision: 3,
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
title: 'From spreadsheet',
revision: 0,
order: ['welcome', 'lunch'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
},
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// identity and revision come from the current rundown
expect(merged.id).toBe('show-rundown');
expect(merged.title).toBe('Main show');
expect(merged.revision).toBe(4);
// structure and order come from the incoming rundown
expect(merged.order).toEqual(['welcome', 'lunch']);
expect(merged.flatOrder).toEqual(incoming.flatOrder);
});
it('deletes current entries that are absent from the incoming rundown', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['welcome', 'keynote'],
entries: {
welcome: makeOntimeEvent({ id: 'welcome' }),
keynote: makeOntimeEvent({ id: 'keynote' }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['welcome'],
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
expect(merged.entries.welcome).toBeDefined();
expect(merged.entries.keynote).toBeUndefined();
});
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// the incoming group fully replaces the previous event, no old data is carried over
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
});
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
});
// the sheet mapped title and note
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
// an empty provided value replaces the current one
expect(keynote.note).toBe('');
});
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
},
});
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Opening keynote',
note: 'from a stale column',
triggers: [],
}),
},
});
// only title is mapped, so note and automations keep the existing values, not the incoming ones
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.note).toBe('green room');
expect(keynote.triggers).toEqual([automation]);
});
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({
id: 'keynote',
title: 'Keynote',
custom: { lighting: 'warm', song: 'intro theme' },
}),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
},
});
// the sheet mapped the title and the lighting custom field, but not song
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
const keynote = merged.entries.keynote as OntimeEvent;
expect(keynote.title).toBe('Opening keynote');
expect(keynote.custom.lighting).toBe('cold');
// an unmapped custom field is kept from the existing event
expect(keynote.custom.song).toBe('intro theme');
});
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
});
it('keeps the existing time strategy when the provided times are ambiguous', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
});
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
});
it('merges a matched group, keeping the fields the sheet cannot express', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
});
// the sheet cannot express a group's target duration, so the incoming group does not carry one
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['session'],
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const session = merged.entries.session as OntimeGroup;
expect(session.title).toBe('New session');
expect(session.targetDuration).toBe(3_600_000);
});
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['reminder'],
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
});
// only title is mapped, so the milestone's note keeps the existing value
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const reminder = merged.entries.reminder as OntimeMilestone;
expect(reminder.title).toBe('Green room reminder');
expect(reminder.note).toBe('call talent');
});
it('does not mutate the current rundown and deep-clones the kept automations', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
// kept automations are a copy, not a shared reference to the current rundown
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
});
it('keeps the not-provided fields through a parseRundown round-trip', () => {
const current = makeRundown({
id: 'show-rundown',
order: ['keynote'],
entries: {
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
},
});
const incoming = makeRundown({
id: 'spreadsheet-rundown',
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
});
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
const parsed = parseRundown(merged, {});
const keynote = parsed.entries.keynote as OntimeEvent;
expect(keynote.triggers).toEqual([automation]);
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
});
});
describe('isLoadedPlayable()', () => {
it('returns true when the loaded event still exists and is playable', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
});
it('returns false when the loaded event was removed', () => {
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the loaded event is now skipped', () => {
const rundown = makeRundown({
order: ['keynote'],
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
});
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
it('returns false when the matched entry is no longer an event', () => {
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
});
});
@@ -1,40 +0,0 @@
import type { OntimeGroup } from 'ontime-types';
import { describe, expect, test } from 'vitest';
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder } from '../rundown.validation.js';
const rundown = makeRundown({
order: ['top', 'group'],
entries: {
top: makeOntimeEvent({ id: 'top', parent: null }),
group: makeOntimeGroup({ id: 'group', entries: ['nested'] }),
nested: makeOntimeEvent({ id: 'nested', parent: 'group' }),
},
});
describe('insertion anchor validation', () => {
test('rejects a missing anchor', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { before: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
test('rejects an anchor from a different order', () => {
expect(() => assertInsertAnchorInOrder(rundown, null, { after: 'nested' })).toThrow(
'Insertion anchor with ID nested is not in the target order',
);
});
test('accepts a group-local anchor', () => {
const group = rundown.entries.group as OntimeGroup;
expect(() => assertInsertAnchorInOrder(rundown, group, { before: 'nested' })).not.toThrow();
});
test('checks clone anchors exist', () => {
expect(() => assertInsertAnchorExists(rundown, { after: 'missing' })).toThrow(
'Insertion anchor with ID missing does not exist',
);
});
});
+43 -41
View File
@@ -439,27 +439,21 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
rundown.entries[nestedEntry.id] = nestedEntry;
}
const position: { after: EntryId | null; before: EntryId | null } = (() => {
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
// by default we insert after the cloned element
let atIndex = rundown.order.indexOf(entry.id) + 1;
const referenceId = options?.after ?? options?.before;
if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
}
const after = getInsertAfterId(rundown, null, options.after, options.before);
const before = options.before === true ? (after ?? null) : (options.before ?? null);
return { after, before };
})();
const atIndex = (() => {
if (position.before !== null) {
return rundown.order.indexOf(position.before);
}
if (position.after !== null) {
return rundown.order.indexOf(position.after) + 1;
}
return rundown.order.length;
})();
}
// we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
@@ -467,33 +461,41 @@ function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): O
return newGroup;
} else {
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
const parent = (() => {
const referenceId = (() => {
if (typeof options?.after === 'string') return options.after;
if (typeof options?.before === 'string') return options.before;
return undefined;
})();
const referenceEntry = referenceId ? rundown.entries[referenceId] : entry;
const parentId = referenceEntry && !isOntimeGroup(referenceEntry) ? referenceEntry.parent : null;
if (parentId === null) return null;
let parent: OntimeGroup | null = null;
const maybeParent = rundown.entries[parentId];
return isOntimeGroup(maybeParent) ? maybeParent : null;
})();
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
const position = (() => {
// if no position is given, we add immediately after the element
if (options?.after === undefined && options?.before === undefined) {
return { after: entry.id, before: null };
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
const after = getInsertAfterId(rundown, parent, options.after, options.before);
const before = options?.before === true ? after : (options.before ?? null);
return { after, before };
})();
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
return addToRundown(rundown, clonedEntry, parent, position.after, position.before);
return addToRundown(rundown, clonedEntry, after, parent);
}
}
@@ -279,22 +279,14 @@ function processEntry<T extends OntimeEntry>(
// 2. handle custom fields - mutates entry
sanitiseCustomFields(customFields, entry);
/*
* we initialise data so that is not calculated for skipped events
* consider especially the day offset, while skipped events have no
* day offset, we match it to the previous element to avoid
* pushing confusing data to the UI
*/
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
entry.delay = 0;
entry.gap = 0;
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
entry.parent = childOfGroup;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(entry)) {
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
entry.dayOffset = rundownMetadata.totalDays as Day;
rundownMetadata.playableEventOrder.push(entry.id);
// first start is always the first event
@@ -1,15 +1,7 @@
import type { Request, Response, Router } from 'express';
import express from 'express';
import { matchedData } from 'express-validator';
import {
ErrorResponse,
OntimeEntry,
ProjectRundowns,
ProjectRundownsList,
RenumberCues,
Rundown,
RundownImportPayload,
} from 'ontime-types';
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -20,9 +12,7 @@ import {
applyDelay,
batchEditEntries,
cloneEntry,
applyImportToRundown,
createNewRundown,
createRundownFromImport,
deleteAllEntries,
deleteEntries,
deleteRundown,
@@ -46,7 +36,6 @@ import {
entryReorderValidator,
entrySwapValidator,
rundownArrayOfIds,
rundownImportValidator,
rundownPatchValidator,
rundownPostValidator,
} from './rundown.validation.js';
@@ -158,39 +147,6 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
}
});
/**
* Applies an imported rundown: override or merge into an existing rundown, or create a new one.
*/
router.post(
'/import',
rundownImportValidator,
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
try {
const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData<RundownImportPayload>(req);
let projectRundowns: ProjectRundowns;
if (mode === 'new') {
projectRundowns = await createRundownFromImport(rundown, customFields);
} else {
// the validator guarantees this for override/merge, the guard narrows the type and adds defence in depth
if (!targetRundownId) {
throw new Error('targetRundownId is required when mode is override or merge');
}
projectRundowns = await applyImportToRundown(
mode,
targetRundownId,
rundown,
customFields,
providedFields ?? { event: [], custom: [] },
);
}
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
@@ -4,7 +4,6 @@ import {
CustomFields,
EntryId,
EventPostPayload,
ImportedFields,
InsertOptions,
LogOrigin,
OntimeEntry,
@@ -13,7 +12,6 @@ import {
ProjectRundowns,
RefetchKey,
Rundown,
RundownImportMergeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
@@ -27,7 +25,6 @@ import { makeNewRundown } from '../../models/dataModel.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
import {
createTransaction,
customFieldMutation,
@@ -36,19 +33,8 @@ import {
rundownMutation,
updateBackgroundRundown,
} from './rundown.dao.js';
import { parseRundown } from './rundown.parser.js';
import type { RundownMetadata } from './rundown.types.js';
import {
generateEvent,
getFirstInsertId,
getIntegerAndFraction,
getLastInsertId,
getPreviousInsertId,
hasChanges,
mergeRundownPreservingFields,
isLoadedPlayable,
} from './rundown.utils.js';
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
/**
* creates a new entry with given data
@@ -56,8 +42,6 @@ import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsert
export async function addEntry(rundownId: string, eventData: EventPostPayload): Promise<OntimeEntry> {
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertSingleInsertAnchor(eventData);
// we allow the user to provide an ID, but make sure it is unique
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
throw new Error(`Event with ID ${eventData.id} already exists`);
@@ -74,29 +58,14 @@ export async function addEntry(rundownId: string, eventData: EventPostPayload):
parent = maybeParent;
}
assertInsertAnchorInOrder(rundown, parent, eventData);
// normalise the position of the event in the rundown order
const insertPosition = (() => {
if (eventData.before !== undefined) {
const beforeId = getFirstInsertId(rundown, parent, eventData.before);
return { afterId: null, beforeId, cueAfterId: getPreviousInsertId(rundown, parent, beforeId) };
}
// if after is an ID, we will try and find the entry
if (eventData.after !== undefined && eventData.after !== true) {
const afterId = getInsertAfterId(rundown, parent, eventData.after);
return { afterId, beforeId: null, cueAfterId: afterId };
}
return { afterId: null, beforeId: null, cueAfterId: getLastInsertId(rundown, parent) };
})();
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, insertPosition.cueAfterId, parent?.id);
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, parent, insertPosition.afterId, insertPosition.beforeId);
rundownMutation.add(rundown, newEntry, afterId, parent);
const { rundown: responseRundown, rundownMetadata, revision } = await commit();
@@ -416,10 +385,7 @@ export async function swapEvents(rundownId: string, fromId: EntryId, toId: Entry
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(rundownId: string, entryId: EntryId, options: InsertOptions): Promise<Rundown> {
assertSingleInsertAnchor(options);
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
assertInsertAnchorExists(rundown, options);
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
@@ -690,8 +656,8 @@ export async function loadRundown(id: string) {
}
/**
* Sets a new rundown in the cache and marks it as the currently loaded one.
* Switching to a rundown always stops playback.
* Sets a new rundown in the cache
* and marks it as the currently loaded one
*/
export async function initRundown(
rundown: Readonly<Rundown>,
@@ -713,25 +679,6 @@ export async function initRundown(
});
}
/**
* Applies a rebuilt version of the currently loaded rundown in place.
* Unlike switching rundowns, this maintains playback when possible
*/
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
const loadedEvent = runtimeService.getLoadedEventId();
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
runtimeService.stop();
}
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
updateRuntimeOnChange(rundownMetadata);
setImmediate(() => {
// notifying the timer hot-reloads the playing event and keeps playback
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
sendRefetch(RefetchKey.ProjectRundowns);
});
}
export async function createNewRundown(title: string) {
const emptyRundown = makeNewRundown();
emptyRundown.title = title;
@@ -796,86 +743,6 @@ export async function duplicateExistingRundown(id: string) {
return dataProvider.getProjectRundowns();
}
/**
* Validates an imported rundown against the resulting custom fields, then commits those custom
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
* validation cannot leave a partial custom-field write behind.
* @throws if the rundown or custom fields fail validation
*/
async function parseImportAndCommitCustomFields(
source: Readonly<Rundown>,
incomingCustomFields: CustomFields,
): Promise<Rundown> {
const dataProvider = getDataProvider();
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
const parsed = parseRundown(source, mergedCustomFields);
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
return parsed;
}
/**
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
* taking structure and order from the incoming data.
* - 'override' replaces all content with the incoming data
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
* (e.g. automations) from the existing entry
*
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
* maintained when the playing event survives.
* @throws if the target rundown does not exist
*/
export async function applyImportToRundown(
strategy: RundownImportMergeStrategy,
targetRundownId: string,
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
providedFields: ImportedFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// throws if the rundown was deleted between preview and apply
const existing = dataProvider.getRundown(targetRundownId);
const source =
strategy === 'merge'
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
if (isCurrentRundown(targetRundownId)) {
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
} else {
await dataProvider.setRundown(parsed.id, parsed);
setImmediate(() => {
sendRefetch(RefetchKey.ProjectRundowns);
});
}
return dataProvider.getProjectRundowns();
}
/**
* Creates a new rundown from an imported rundown and loads it,
* so the user immediately sees the imported data.
* Loading a rundown stops playback (as with any rundown switch).
*/
export async function createRundownFromImport(
incomingRundown: Rundown,
incomingCustomFields: CustomFields,
): Promise<ProjectRundowns> {
const dataProvider = getDataProvider();
// assign a fresh id so we never collide with an existing rundown
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
parsed.revision = 0;
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
await initRundown(parsed, dataProvider.getCustomFields(), true);
return dataProvider.getProjectRundowns();
}
/**
* Deletes a rundown
* @throws if attempting to delete the loaded rundown or the last rundown in the project
@@ -2,7 +2,6 @@ import {
CustomFields,
EntryCustomFields,
EntryId,
ImportedFields,
OntimeBaseEvent,
OntimeDelay,
OntimeEntry,
@@ -12,14 +11,12 @@ import {
ProjectRundown,
ProjectRundowns,
Rundown,
RundownEntries,
SupportedEntry,
TimeStrategy,
isOntimeDelay,
isOntimeEvent,
isOntimeGroup,
isOntimeMilestone,
isPlayableEvent,
} from 'ontime-types';
import {
createDelay,
@@ -70,50 +67,6 @@ export function generateEvent(
throw new Error('Invalid event type');
}
/**
* Gets the last valid insertion reference for a top-level rundown or group order.
* Used when appending entries and when generating cues from the preceding entry.
*/
export function getLastInsertId(rundown: Rundown, parent: OntimeGroup | null): EntryId | null {
const insertionList = parent ? parent.entries : rundown.order;
return insertionList[insertionList.length - 1] ?? null;
}
/**
* Resolves a `before` insertion option to the first entry in the relevant order when `true` is provided.
* String values are already explicit anchors and are returned unchanged.
*/
export function getFirstInsertId(rundown: Rundown, parent: OntimeGroup | null, before: EntryId | true): EntryId | null {
if (before !== true) {
return before;
}
const insertionList = parent ? parent.entries : rundown.order;
return insertionList[0] ?? null;
}
/**
* Gets the sibling before a `before` insertion anchor in the top-level or group order.
* Returns `null` when the new entry will be inserted at the start.
*/
export function getPreviousInsertId(
rundown: Rundown,
parent: OntimeGroup | null,
beforeId: EntryId | null,
): EntryId | null {
if (beforeId === null) {
return null;
}
const insertionList = parent ? parent.entries : rundown.order;
const beforeIndex = insertionList.indexOf(beforeId);
if (beforeIndex < 1) {
return null;
}
return insertionList[beforeIndex - 1] ?? null;
}
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
@@ -272,83 +225,6 @@ export function getUniqueId(rundown: Rundown): EntryId {
return id;
}
/**
* Builds an entry patch containing exactly the fields the spreadsheet supplied, for both built-in
* and custom fields. Everything the sheet did not map is left out, so applying the patch keeps the
* existing value for those fields.
*/
function buildImportPatch(entry: OntimeEntry, providedFields: ImportedFields): Partial<OntimeEntry> {
const source = entry as Record<string, unknown>;
const patch: Record<string, unknown> = {};
for (const field of providedFields.event) {
patch[field] = source[field];
}
if (providedFields.custom.length > 0) {
const entryCustom = (source.custom ?? {}) as EntryCustomFields;
const custom: EntryCustomFields = {};
for (const key of providedFields.custom) {
custom[key] = entryCustom[key] ?? '';
}
patch.custom = custom;
}
return patch as Partial<OntimeEntry>;
}
/**
* Merges an imported rundown into an existing one
* - the incoming rundown is the source of truth for entry identity and structure (order + grouping)
* - a matched entry of the same type is merged field-by-field: a field the sheet provided overwrites
* (even when empty), a field the sheet cannot express (an event's automations, a group's target
* duration, an unmapped custom field) is kept from the existing entry
* - a new id, or an id whose type changed, takes the incoming entry wholesale
* - existing entries absent from the incoming rundown are dropped
*/
export function mergeRundownPreservingFields(
incoming: Readonly<Rundown>,
existing: Readonly<Rundown>,
providedFields: ImportedFields,
): Rundown {
const entries: RundownEntries = {};
for (const [id, incomingEntry] of Object.entries(incoming.entries)) {
const existingEntry = existing.entries[id];
// a new id, or one whose type changed, is not compatible for a merge: take the incoming data
if (existingEntry === undefined || existingEntry.type !== incomingEntry.type) {
entries[id] = incomingEntry;
continue;
}
// merge the sheet's data onto the existing entry through the canonical patch function, which
// keeps every unmapped field and infers an event's time strategy from the provided times
const merged = applyPatchToEntry(existingEntry, buildImportPatch(incomingEntry, providedFields));
// grouping comes from the sheet structure, not a data column: a group owns its children, every
// other entry knows its parent
const structure = isOntimeGroup(incomingEntry)
? { entries: incomingEntry.entries }
: { parent: incomingEntry.parent };
entries[id] = structuredClone({ ...merged, ...structure });
}
return {
id: existing.id,
title: existing.title,
order: [...incoming.order],
flatOrder: [...incoming.flatOrder],
revision: existing.revision + 1,
entries,
};
}
/**
* Whether the currently playing event survives a change to its rundown,
* i.e. it still exists and is playable in the new version.
*/
export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundown>): boolean {
const entry = rundown.entries[loadedEventId];
return entry !== undefined && isOntimeEvent(entry) && isPlayableEvent(entry);
}
/** List of event properties which do not need the rundown to be regenerated */
enum RegenerateWhitelist {
'id', // adding it for completeness, users cannot change ID
@@ -1,5 +1,4 @@
import { body, param } from 'express-validator';
import type { EntryId, InsertOptions, OntimeGroup, Rundown } from 'ontime-types';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
@@ -12,53 +11,20 @@ export const rundownPatchValidator = [
requestValidationFunction,
];
export const rundownImportValidator = [
body('mode').isString().isIn(['override', 'merge', 'new']),
body('targetRundownId')
.if(body('mode').isIn(['override', 'merge']))
.isString()
.trim()
.notEmpty()
.withMessage('targetRundownId is required when mode is override or merge'),
body('rundown').isObject(),
body('rundown.entries').isObject(),
body('rundown.order').isArray(),
body('rundown.flatOrder').isArray(),
body('customFields').isObject(),
body('providedFields').optional().isObject(),
body('providedFields.event').optional().isArray(),
body('providedFields.event.*').isString(),
body('providedFields.custom').optional().isArray(),
body('providedFields.custom.*').isString(),
requestValidationFunction,
];
// #endregion operations on project rundowns ======================
// #region operations on rundown entries ==========================
export const entryPostValidator = [
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
body('after')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for after are an ID or true.'),
body('before')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for before are an ID or true.'),
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
];
export const clonePostValidator = [
body('after')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for after are an ID or true.'),
body('before')
.optional()
.custom((value) => value === true || typeof value === 'string')
.withMessage('Allowed values for before are an ID or true.'),
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
];
@@ -106,44 +72,4 @@ export const entryRenumberValidator = [
requestValidationFunction,
];
/**
* Ensures callers use a single insertion anchor so placement is unambiguous.
*/
export function assertSingleInsertAnchor(options: InsertOptions) {
if (options.after !== undefined && options.before !== undefined) {
throw new Error('Use only one insertion anchor: after or before');
}
}
/**
* Ensures an ID anchor exists and belongs to the order it is intended to position within.
*/
export function assertInsertAnchorInOrder(rundown: Rundown, parent: OntimeGroup | null, options: InsertOptions) {
const anchor = getStringInsertAnchor(options);
if (anchor === undefined) return;
if (!Object.hasOwn(rundown.entries, anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
}
const insertionList = parent ? parent.entries : rundown.order;
if (!insertionList.includes(anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} is not in the target order`);
}
}
/** Ensures an ID anchor refers to an entry in the rundown. */
export function assertInsertAnchorExists(rundown: Rundown, options: InsertOptions) {
const anchor = getStringInsertAnchor(options);
if (anchor !== undefined && !Object.hasOwn(rundown.entries, anchor)) {
throw new Error(`Insertion anchor with ID ${anchor} does not exist`);
}
}
function getStringInsertAnchor(options: InsertOptions): EntryId | undefined {
if (typeof options.after === 'string') return options.after;
if (typeof options.before === 'string') return options.before;
return undefined;
}
// #endregion operations on rundown entries =======================
@@ -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();
}
@@ -153,41 +153,6 @@ describe('mcp.service', () => {
expect(result.created.map((entry) => entry.id)).toEqual(['entry-1', 'entry-2', 'entry-3', 'entry-4']);
});
it('omits insert anchors when creating an entry without a position', async () => {
await createEntryForMcp({
type: SupportedEntry.Milestone,
title: 'End marker',
});
expect(addEntryMock).toHaveBeenCalledWith(
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Milestone, title: 'End marker' }),
);
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('after');
expect(addEntryMock.mock.calls[0][1]).not.toHaveProperty('before');
});
it('uses before true for the first batch entry and chains the rest', async () => {
await batchCreateEntriesForMcp({
before: true,
entries: [
{ type: SupportedEntry.Event, title: 'First' },
{ type: SupportedEntry.Event, title: 'Second' },
],
});
expect(addEntryMock).toHaveBeenNthCalledWith(
1,
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Event, title: 'First', before: true }),
);
expect(addEntryMock).toHaveBeenNthCalledWith(
2,
'loaded-rundown',
expect.objectContaining({ type: SupportedEntry.Event, title: 'Second', after: 'entry-1' }),
);
});
it('rejects nested groups before creating entries', async () => {
await expect(
batchCreateEntriesForMcp({

Some files were not shown because too many files have changed in this diff Show More