mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa22ebc09 | |||
| 41d3031a39 | |||
| 73a61887a6 | |||
| e1f6b22d19 | |||
| 0ff226f0ec |
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.11.0",
|
||||
"version": "4.10.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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 ==========================
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
dayInMs,
|
||||
formatFromMillis,
|
||||
getExpectedEnd,
|
||||
getExpectedStart,
|
||||
@@ -31,11 +30,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
|
||||
@@ -179,21 +173,28 @@ export function getExpectedTimesFromExtendedEvent(
|
||||
) {
|
||||
if (event === null) return { expectedStart: 0, timeToStart: 0, expectedEnd: 0, plannedEnd: 0 };
|
||||
|
||||
const expectedStartState = {
|
||||
totalGap: event.totalGap,
|
||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||
...state,
|
||||
};
|
||||
|
||||
const expectedStart = getExpectedStart(
|
||||
{ timeStart: event.timeStart, delay: event.delay, dayOffset: event.dayOffset },
|
||||
{
|
||||
totalGap: event.totalGap,
|
||||
isLinkedToLoaded: event.isLinkedToLoaded,
|
||||
...state,
|
||||
},
|
||||
expectedStartState,
|
||||
);
|
||||
const expectedEnd = getExpectedEnd(event, expectedStart, state.currentDay);
|
||||
const plannedEnd = event.timeStart + event.duration + event.delay;
|
||||
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const delayToAdd = event.countToEnd ? 0 : event.delay;
|
||||
const plannedEnd = event.timeStart + event.duration + delayToAdd;
|
||||
|
||||
// we let timeToStart go negative to allow the UI to show due timers
|
||||
const timeToStart = expectedStart - state.clock;
|
||||
|
||||
return {
|
||||
expectedStart,
|
||||
timeToStart: expectedStart - state.clock,
|
||||
expectedEnd,
|
||||
timeToStart,
|
||||
expectedEnd: getExpectedEnd(event, expectedStartState),
|
||||
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'
|
||||
/>
|
||||
|
||||
+28
-51
@@ -5,7 +5,6 @@ import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
@@ -72,8 +71,8 @@ export default function OntimeActionForm({
|
||||
{ value: 'playback-pause', label: 'Playback: pause' },
|
||||
{ value: 'playback-roll', label: 'Playback: roll' },
|
||||
|
||||
{ value: 'message-set', label: 'Primary Message' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message' },
|
||||
{ value: 'message-set', label: 'Primary Message: set' },
|
||||
{ value: 'message-secondary', label: 'Secondary Message: source' },
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||
@@ -97,12 +96,7 @@ export default function OntimeActionForm({
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Timer is finished'
|
||||
/>
|
||||
<Input {...register(`outputs.${index}.text`)} fluid placeholder='eg: Timer is finished' />
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
@@ -126,48 +120,31 @@ export default function OntimeActionForm({
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
value={watch(`outputs.${index}.text`) ?? ''}
|
||||
fluid
|
||||
placeholder='eg: Next up: keynote'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'no-change' | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// no-change -> leave the current secondary source untouched
|
||||
if (value === 'no-change') {
|
||||
setValue(`outputs.${index}.secondarySource`, undefined, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`) ?? 'no-change'}
|
||||
options={[
|
||||
{ value: 'no-change', label: 'No change' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
</>
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select<SecondarySource | 'null' | null>
|
||||
onValueChange={(value) => {
|
||||
// null -> no selection
|
||||
if (value === null) return;
|
||||
// 'null' -> clear the secondary source
|
||||
if (value === 'null') {
|
||||
setValue(`outputs.${index}.secondarySource`, null, { shouldDirty: true });
|
||||
return;
|
||||
}
|
||||
setValue(`outputs.${index}.secondarySource`, value, { shouldDirty: true });
|
||||
}}
|
||||
value={watch(`outputs.${index}.secondarySource`)}
|
||||
options={[
|
||||
{ value: null, label: 'Select secondary source' },
|
||||
{ value: 'aux1', label: 'Auxiliary timer 1' },
|
||||
{ value: 'aux2', label: 'Auxiliary timer 2' },
|
||||
{ value: 'aux3', label: 'Auxiliary timer 3' },
|
||||
{ value: 'secondary', label: 'Secondary' },
|
||||
{ value: 'null', label: 'None' }, // allow the user to clear the secondary source
|
||||
]}
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function TriggersList(props: TriggersListProps) {
|
||||
<tbody>
|
||||
{!showForm && triggers.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
label='Create a trigger to run an automation'
|
||||
label='Create an automation to attach triggers to'
|
||||
handleClick={canAdd ? () => setShowForm(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
+17
-55
@@ -1,67 +1,29 @@
|
||||
.inputShell {
|
||||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.expandButton {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.positioner {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.popup {
|
||||
box-sizing: border-box;
|
||||
min-width: var(--anchor-width);
|
||||
max-width: var(--available-width);
|
||||
border: 1px solid $gray-1000;
|
||||
border-radius: $component-border-radius-md;
|
||||
.suggestions {
|
||||
background: $gray-1250;
|
||||
color: $label-gray;
|
||||
}
|
||||
color: $ui-white;
|
||||
|
||||
.list {
|
||||
box-sizing: border-box;
|
||||
max-height: min(20rem, var(--available-height));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
z-index: $zindex-floating;
|
||||
padding-block: 0.25rem;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0.5rem;
|
||||
outline: 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
color: $label-gray;
|
||||
|
||||
&[data-highlighted] {
|
||||
li {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
color: $ui-white;
|
||||
background: $blue-700;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedEditor {
|
||||
min-height: min(18rem, 45vh);
|
||||
font-family: monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
margin-right: auto;
|
||||
color: $label-gray;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
+43
-232
@@ -1,258 +1,69 @@
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import type { ChangeEvent, ReactNode, Ref } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { IoExpandOutline } from 'react-icons/io5';
|
||||
import { mergeRefs, useClickOutside } from '@mantine/hooks';
|
||||
import { forwardRef, useMemo, useState } from 'react';
|
||||
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../../common/components/buttons/IconButton';
|
||||
import { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import Textarea from '../../../../../common/components/input/textarea/Textarea';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import Input, { type InputProps } from '../../../../../common/components/input/input/Input';
|
||||
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
|
||||
import { cx } from '../../../../../common/utils/styleUtils';
|
||||
import { makeAutoCompleteList } from './templateInput.utils';
|
||||
import { useTemplateAutocomplete } from './useTemplateAutocomplete';
|
||||
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
|
||||
|
||||
import inputStyle from '../../../../../common/components/input/input/Input.module.scss';
|
||||
import style from './TemplateInput.module.scss';
|
||||
|
||||
interface TemplateInputProps extends Omit<InputProps, 'value'> {
|
||||
ref?: Ref<HTMLInputElement>;
|
||||
value?: string;
|
||||
}
|
||||
interface TemplateInputProps extends InputProps {}
|
||||
|
||||
interface TemplateEditorModalProps {
|
||||
autocompleteList: string[];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
type TemplateAutocompleteState = ReturnType<typeof useTemplateAutocomplete<HTMLInputElement>>;
|
||||
|
||||
interface TemplateAutocompleteRootProps {
|
||||
autocomplete: TemplateAutocompleteState;
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
} else if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function emitInputChange(name: string | undefined, value: string, onChange: InputProps['onChange']) {
|
||||
onChange?.({
|
||||
target: { name, value },
|
||||
currentTarget: { name, value },
|
||||
} as ChangeEvent<HTMLInputElement>);
|
||||
}
|
||||
|
||||
export default function TemplateInput({
|
||||
className,
|
||||
disabled,
|
||||
fluid,
|
||||
height = 'medium',
|
||||
onChange,
|
||||
readOnly,
|
||||
ref,
|
||||
value,
|
||||
variant = 'subtle',
|
||||
...rest
|
||||
}: TemplateInputProps) {
|
||||
const TemplateInput = forwardRef(function TemplateInput(props: TemplateInputProps, ref) {
|
||||
const { value, onChange, ...rest } = props;
|
||||
const { data } = useCustomFields();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const localRef = useClickOutside(() => setShowSuggestions(false));
|
||||
|
||||
const autocompleteList = useMemo(() => {
|
||||
return makeAutoCompleteList(data);
|
||||
}, [data]);
|
||||
|
||||
const updateInputValue = useCallback(
|
||||
(nextValue: string) => {
|
||||
setInputValue(nextValue);
|
||||
emitInputChange(rest.name, nextValue, onChange);
|
||||
},
|
||||
[onChange, rest.name],
|
||||
);
|
||||
const [inputValue, setInputValue] = useState(value || '');
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
const autocomplete = useTemplateAutocomplete(inputValue, autocompleteList, inputRef, updateInputValue);
|
||||
const { setCursorForValue } = autocomplete;
|
||||
|
||||
// Keep the local autocomplete input in sync when react-hook-form resets or swaps field-array values.
|
||||
useEffect(() => {
|
||||
const nextValue = value || '';
|
||||
setInputValue(nextValue);
|
||||
setCursorForValue(nextValue, nextValue.length);
|
||||
}, [setCursorForValue, value]);
|
||||
|
||||
const setInputRef = useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
inputRef.current = element;
|
||||
assignRef(ref, element);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
const openExpandedEditor = () => {
|
||||
autocomplete.setShowSuggestions(false);
|
||||
setIsExpanded(true);
|
||||
const updateSuggestions = (value: string) => {
|
||||
const template = selectFromLastTemplate(value);
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
|
||||
};
|
||||
|
||||
const closeExpandedEditor = () => {
|
||||
setIsExpanded(false);
|
||||
};
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
|
||||
const saveExpandedEditor = (nextValue: string) => {
|
||||
updateInputValue(nextValue);
|
||||
autocomplete.setCursorForValue(nextValue, nextValue.length);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={inputValue}>
|
||||
<div className={cx([style.inputShell, fluid && style.fluid])}>
|
||||
<BaseAutocomplete.Input
|
||||
ref={setInputRef}
|
||||
className={cx([
|
||||
inputStyle.input,
|
||||
inputStyle[variant],
|
||||
inputStyle[height],
|
||||
fluid && inputStyle.fluid,
|
||||
style.input,
|
||||
className,
|
||||
])}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label='Expand template editor'
|
||||
className={style.expandButton}
|
||||
disabled={disabled || readOnly}
|
||||
onClick={openExpandedEditor}
|
||||
size='small'
|
||||
title='Expand template editor'
|
||||
variant='ghosted-white'
|
||||
>
|
||||
<IoExpandOutline />
|
||||
</IconButton>
|
||||
</div>
|
||||
</TemplateAutocompleteRoot>
|
||||
<TemplateEditorModal
|
||||
autocompleteList={autocompleteList}
|
||||
isOpen={isExpanded}
|
||||
onClose={closeExpandedEditor}
|
||||
onSave={saveExpandedEditor}
|
||||
value={inputValue}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateAutocompleteRoot({ autocomplete, children, value }: TemplateAutocompleteRootProps) {
|
||||
return (
|
||||
<BaseAutocomplete.Root
|
||||
items={autocomplete.suggestions}
|
||||
autoHighlight
|
||||
highlightItemOnHover
|
||||
mode='none'
|
||||
open={autocomplete.open}
|
||||
value={value}
|
||||
onOpenChange={autocomplete.setShowSuggestions}
|
||||
onValueChange={autocomplete.handleValueChange}
|
||||
>
|
||||
{children}
|
||||
<TemplateSuggestionPopup />
|
||||
</BaseAutocomplete.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEditorModal({ autocompleteList, isOpen, onClose, onSave, value }: TemplateEditorModalProps) {
|
||||
const expandedInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [draftValue, setDraftValue] = useState(value);
|
||||
const autocomplete = useTemplateAutocomplete(draftValue, autocompleteList, expandedInputRef, setDraftValue);
|
||||
const { setShowSuggestions } = autocomplete;
|
||||
|
||||
// Reset the draft whenever the modal opens so cancel never leaks unsaved changes.
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
if (event.target.value.endsWith('{')) {
|
||||
setShowSuggestions(true);
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
|
||||
setShowSuggestions(false);
|
||||
} else if (showSuggestions) {
|
||||
setSuggestions(updateSuggestions(event.target.value));
|
||||
}
|
||||
|
||||
setDraftValue(value);
|
||||
setShowSuggestions(false);
|
||||
}, [isOpen, setShowSuggestions, value]);
|
||||
|
||||
const handleClose = () => {
|
||||
setShowSuggestions(false);
|
||||
onClose();
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSuggestion = (value: string) => {
|
||||
setInputValue((prev) => {
|
||||
const remaining = matchRemaining(prev as string, value);
|
||||
return prev + remaining;
|
||||
});
|
||||
setShowSuggestions(false);
|
||||
onSave(draftValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Edit template'
|
||||
bodyElements={
|
||||
<TemplateAutocompleteRoot autocomplete={autocomplete} value={draftValue}>
|
||||
<BaseAutocomplete.Input
|
||||
autoFocus
|
||||
className={style.expandedEditor}
|
||||
onClick={autocomplete.updateCursor}
|
||||
onFocus={autocomplete.updateCursor}
|
||||
onKeyUp={autocomplete.updateCursor}
|
||||
onSelect={autocomplete.updateCursor}
|
||||
render={<Textarea ref={expandedInputRef} fluid resize='none' rows={8} />}
|
||||
/>
|
||||
</TemplateAutocompleteRoot>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<span className={style.footerHint}>Start a template with {'{{'} to see autocomplete.</span>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className={style.wrapper} ref={mergeRefs(localRef, ref)}>
|
||||
<Input value={inputValue} {...rest} onChange={handleInputChange} fluid />
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<ul className={style.suggestions}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function TemplateSuggestionPopup() {
|
||||
return (
|
||||
<BaseAutocomplete.Portal>
|
||||
<BaseAutocomplete.Positioner side='bottom' align='start' className={style.positioner}>
|
||||
<BaseAutocomplete.Popup className={style.popup}>
|
||||
<BaseAutocomplete.List className={style.list}>
|
||||
<BaseAutocomplete.Collection>
|
||||
{(suggestion: string) => (
|
||||
<BaseAutocomplete.Item key={suggestion} value={suggestion} className={style.item}>
|
||||
{suggestion}
|
||||
</BaseAutocomplete.Item>
|
||||
)}
|
||||
</BaseAutocomplete.Collection>
|
||||
</BaseAutocomplete.List>
|
||||
</BaseAutocomplete.Popup>
|
||||
</BaseAutocomplete.Positioner>
|
||||
</BaseAutocomplete.Portal>
|
||||
);
|
||||
}
|
||||
export default TemplateInput;
|
||||
|
||||
+1
-52
@@ -1,4 +1,4 @@
|
||||
import { completeTemplateAtCursor, matchRemaining, selectActiveTemplate } from '../templateInput.utils';
|
||||
import { matchRemaining } from '../templateInput.utils';
|
||||
|
||||
describe('matchRemaining()', () => {
|
||||
it('should return a partial string needed for autocomplete', () => {
|
||||
@@ -15,54 +15,3 @@ describe('matchRemaining()', () => {
|
||||
expect(matchRemaining('test', 'test')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectActiveTemplate()', () => {
|
||||
it('returns the last unclosed template fragment', () => {
|
||||
expect(selectActiveTemplate('send {{event')).toBe('{{event');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}} and {{event')).toBe('{{event');
|
||||
});
|
||||
|
||||
it('ignores single braces and closed templates', () => {
|
||||
expect(selectActiveTemplate('send {')).toBe('');
|
||||
expect(selectActiveTemplate('send {{eventNow.title}}')).toBe('');
|
||||
});
|
||||
|
||||
it('only considers templates before the cursor', () => {
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 14)).toBe('');
|
||||
expect(selectActiveTemplate('send {{event}} then {{timer', 27)).toBe('{{timer');
|
||||
});
|
||||
|
||||
it('selects a partial template when the cursor is inside a completed template', () => {
|
||||
expect(selectActiveTemplate('send {{timer.current}} after', 12)).toBe('{{timer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeTemplateAtCursor()', () => {
|
||||
it('completes the active template before the cursor', () => {
|
||||
expect(completeTemplateAtCursor('send {{timer after', '{{timer.current}}', 12)).toEqual({
|
||||
value: 'send {{timer.current}} after',
|
||||
cursorIndex: 22,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves text before and after the cursor', () => {
|
||||
expect(completeTemplateAtCursor('before {{event after', '{{eventNow.title}}', 14)).toEqual({
|
||||
value: 'before {{eventNow.title}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves a following template when completing between templates', () => {
|
||||
expect(completeTemplateAtCursor('{{clock}} and {{timer then {{eventNow.title}}', '{{timer.current}}', 21)).toEqual({
|
||||
value: '{{clock}} and {{timer.current}} then {{eventNow.title}}',
|
||||
cursorIndex: 31,
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces the whole template when the cursor is inside a completed template', () => {
|
||||
expect(completeTemplateAtCursor('before {{timer.current}} after', '{{timer.duration}}', 15)).toEqual({
|
||||
value: 'before {{timer.duration}} after',
|
||||
cursorIndex: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-64
@@ -54,16 +54,6 @@ const eventStaticPropertiesNext = [
|
||||
'{{eventNext.delay}}',
|
||||
];
|
||||
|
||||
const groupStaticPropertiesNow = [
|
||||
'{{groupNow.id}}',
|
||||
'{{groupNow.title}}',
|
||||
'{{groupNow.note}}',
|
||||
'{{groupNow.colour}}',
|
||||
'{{groupNow.timeStart}}',
|
||||
'{{groupNow.timeEnd}}',
|
||||
'{{groupNow.duration}}',
|
||||
];
|
||||
|
||||
const staticAuxProperties = (index: 1 | 2 | 3) => [
|
||||
`{{auxtimer${index}.current}}`,
|
||||
`{{auxtimer${index}.duration}}`,
|
||||
@@ -85,19 +75,12 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] {
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
|
||||
...eventStaticPropertiesNext,
|
||||
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
|
||||
...groupStaticPropertiesNow,
|
||||
...Object.entries(customFields).map(([key]) => `{{groupNow.custom.${key}}}`),
|
||||
...staticAuxProperties(1),
|
||||
...staticAuxProperties(2),
|
||||
...staticAuxProperties(3),
|
||||
];
|
||||
}
|
||||
|
||||
interface TemplateCompletion {
|
||||
cursorIndex: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the partial string b needed to autocomplete string a
|
||||
* @example matchRemaining('te', 'test') -> 'st'
|
||||
@@ -128,54 +111,13 @@ export function matchRemaining(a: string, b: string) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActiveTemplateRange(text: string, cursorIndex = text.length) {
|
||||
const textBeforeCursor = text.slice(0, cursorIndex);
|
||||
const start = textBeforeCursor.lastIndexOf('{{');
|
||||
if (start === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeBeforeCursor = textBeforeCursor.lastIndexOf('}}');
|
||||
if (closeBeforeCursor > start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeAfterStart = text.indexOf('}}', start);
|
||||
const nextStartAfterCursor = text.indexOf('{{', cursorIndex);
|
||||
const closesBeforeNextTemplate = nextStartAfterCursor === -1 || closeAfterStart < nextStartAfterCursor;
|
||||
const end = closeAfterStart !== -1 && closesBeforeNextTemplate ? closeAfterStart + 2 : cursorIndex;
|
||||
|
||||
return {
|
||||
end,
|
||||
start,
|
||||
template: text.slice(start, cursorIndex),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the last unclosed starting template before the cursor.
|
||||
* Selects the last starting template in a string
|
||||
*/
|
||||
export function selectActiveTemplate(text: string, cursorIndex = text.length) {
|
||||
return getActiveTemplateRange(text, cursorIndex)?.template ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the active template fragment before the cursor with the selected suggestion.
|
||||
*/
|
||||
export function completeTemplateAtCursor(
|
||||
text: string,
|
||||
suggestion: string,
|
||||
cursorIndex = text.length,
|
||||
): TemplateCompletion {
|
||||
const activeTemplateRange = getActiveTemplateRange(text, cursorIndex);
|
||||
if (!activeTemplateRange) {
|
||||
const value = text + matchRemaining(text, suggestion);
|
||||
return { value, cursorIndex: value.length };
|
||||
export function selectFromLastTemplate(text: string) {
|
||||
const lastBraceIndex = text.lastIndexOf('{{');
|
||||
if (lastBraceIndex !== -1) {
|
||||
return text.slice(lastBraceIndex);
|
||||
}
|
||||
|
||||
const value = `${text.slice(0, activeTemplateRange.start)}${suggestion}${text.slice(activeTemplateRange.end)}`;
|
||||
return {
|
||||
value,
|
||||
cursorIndex: activeTemplateRange.start + suggestion.length,
|
||||
};
|
||||
return '';
|
||||
}
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { Autocomplete as BaseAutocomplete } from '@base-ui/react/autocomplete';
|
||||
import { useCallback, useMemo, useState, type RefObject } from 'react';
|
||||
|
||||
import { completeTemplateAtCursor, selectActiveTemplate } from './templateInput.utils';
|
||||
|
||||
type TemplateElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
function getCursorIndex(element: TemplateElement | null, fallback: string) {
|
||||
return element?.selectionStart ?? fallback.length;
|
||||
}
|
||||
|
||||
function focusCursor(element: TemplateElement | null, cursorIndex: number) {
|
||||
requestAnimationFrame(() => {
|
||||
element?.focus();
|
||||
element?.setSelectionRange(cursorIndex, cursorIndex);
|
||||
});
|
||||
}
|
||||
|
||||
export function useTemplateAutocomplete<T extends TemplateElement>(
|
||||
value: string,
|
||||
autocompleteList: string[],
|
||||
elementRef: RefObject<T | null>,
|
||||
onValueChange: (value: string) => void,
|
||||
) {
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [cursor, setCursor] = useState(value.length);
|
||||
|
||||
const activeTemplate = selectActiveTemplate(value, cursor);
|
||||
const suggestions = useMemo(() => {
|
||||
if (!activeTemplate) {
|
||||
return [];
|
||||
}
|
||||
return autocompleteList.filter((suggestion) => suggestion.startsWith(activeTemplate));
|
||||
}, [activeTemplate, autocompleteList]);
|
||||
|
||||
const setCursorForValue = useCallback((nextValue: string, cursorIndex: number) => {
|
||||
setCursor(cursorIndex);
|
||||
setShowSuggestions(Boolean(selectActiveTemplate(nextValue, cursorIndex)));
|
||||
}, []);
|
||||
|
||||
const updateCursor = useCallback(() => {
|
||||
const cursorIndex = getCursorIndex(elementRef.current, value);
|
||||
setCursorForValue(value, cursorIndex);
|
||||
}, [elementRef, setCursorForValue, value]);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(nextValue: string, eventDetails: BaseAutocomplete.Root.ChangeEventDetails) => {
|
||||
if (eventDetails.reason === 'item-press') {
|
||||
eventDetails.cancel();
|
||||
const completed = completeTemplateAtCursor(value, nextValue, cursor);
|
||||
setCursorForValue(completed.value, completed.cursorIndex);
|
||||
onValueChange(completed.value);
|
||||
focusCursor(elementRef.current, completed.cursorIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorIndex = getCursorIndex(elementRef.current, nextValue);
|
||||
setCursorForValue(nextValue, cursorIndex);
|
||||
onValueChange(nextValue);
|
||||
},
|
||||
[cursor, elementRef, onValueChange, setCursorForValue, value],
|
||||
);
|
||||
|
||||
return {
|
||||
handleValueChange,
|
||||
open: showSuggestions && suggestions.length > 0,
|
||||
setCursorForValue,
|
||||
setShowSuggestions,
|
||||
suggestions,
|
||||
updateCursor,
|
||||
};
|
||||
}
|
||||
-7
@@ -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));
|
||||
|
||||
+20
-57
@@ -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}
|
||||
|
||||
-55
@@ -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>
|
||||
);
|
||||
}
|
||||
-9
@@ -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;
|
||||
}
|
||||
|
||||
+31
-133
@@ -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
-68
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+3
-58
@@ -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.
|
||||
*/
|
||||
|
||||
+24
-102
@@ -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;
|
||||
}
|
||||
|
||||
+14
-126
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
+16
-70
@@ -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,
|
||||
|
||||
+22
-9
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 { formatDuration } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
import style from './PlaybackTimer.module.scss';
|
||||
@@ -79,7 +79,7 @@ function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isO
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -44,9 +44,6 @@ function EventEditorEmpty() {
|
||||
<Separator />
|
||||
<Combo keys={['PgDn']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to current entry'>
|
||||
<Combo keys={[deviceAlt, 'L']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Deselect entry'>
|
||||
<Combo keys={['Esc']} />
|
||||
</Shortcut>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
-9
@@ -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'),
|
||||
|
||||
@@ -17,7 +17,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;
|
||||
@@ -36,6 +36,10 @@ $skip-opacity: 0.2;
|
||||
background-color: $gray-1325;
|
||||
}
|
||||
|
||||
&.countToEnd {
|
||||
--status-color-active-override: #{$orange-400};
|
||||
}
|
||||
|
||||
&.play {
|
||||
background-color: $active-green;
|
||||
@include declare-overrides;
|
||||
@@ -137,7 +141,6 @@ $skip-opacity: 0.2;
|
||||
align-items: center;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eventTimers.editMode:hover {
|
||||
@@ -233,10 +236,6 @@ $skip-opacity: 0.2;
|
||||
color: var(--status-color-active-override, $active-indicator);
|
||||
}
|
||||
|
||||
.statusIcon.countToEndStatus {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.statusIcon.warning {
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
@@ -253,6 +253,7 @@ export default function RundownEvent({
|
||||
|
||||
const blockClasses = cx([
|
||||
style.rundownEvent,
|
||||
countToEnd && style.countToEnd,
|
||||
skip && style.skip,
|
||||
isPast && style.past,
|
||||
loaded && style.loaded,
|
||||
|
||||
@@ -115,6 +115,7 @@ function RundownEventInner({
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
@@ -157,13 +158,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} />
|
||||
|
||||
+2
-25
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,7 +22,6 @@ interface TimeInputFlowProps {
|
||||
linkStart: boolean;
|
||||
delay: number;
|
||||
showLabels?: boolean;
|
||||
showWarnings?: boolean;
|
||||
}
|
||||
|
||||
export default memo(TimeInputFlow);
|
||||
@@ -34,7 +34,6 @@ function TimeInputFlow({
|
||||
linkStart,
|
||||
delay,
|
||||
showLabels,
|
||||
showWarnings = true,
|
||||
}: TimeInputFlowProps) {
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
|
||||
@@ -132,19 +131,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,9 +151,8 @@ type ScheduleTimeProps = {
|
||||
showExpected: boolean;
|
||||
};
|
||||
//TODO: consider relative mode
|
||||
export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const { event, showExpected } = props;
|
||||
const { timeStart, duration, delay, expectedStart, expectedEnd } = event;
|
||||
export function ScheduleTime({ event, showExpected }: ScheduleTimeProps) {
|
||||
const { timeStart, duration, delay, expectedStart, expectedEnd, countToEnd } = event;
|
||||
|
||||
const plannedStart = timeStart + delay + event.dayOffset * dayInMs;
|
||||
|
||||
@@ -163,7 +162,14 @@ export function ScheduleTime(props: ScheduleTimeProps) {
|
||||
const plannedStateClass = isExpectedValueShow ? 'sub__schedule--strike' : delay !== 0 ? 'sub__schedule--delayed' : '';
|
||||
|
||||
const expectedStateClass = `sub__schedule--${getOffsetState(expectedStart - plannedStart)}`;
|
||||
const plannedEnd = plannedStart + duration + delay;
|
||||
|
||||
// count to end events are fixed to the scheduled end and ignore delays
|
||||
const plannedEnd = (() => {
|
||||
if (countToEnd) {
|
||||
return timeStart + event.dayOffset * dayInMs + duration;
|
||||
}
|
||||
return plannedStart + duration;
|
||||
})();
|
||||
const expectedEndClass = `sub__schedule--${getOffsetState(expectedEnd - plannedEnd)}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { getExpectedEnd, getExpectedStart } from 'ontime-utils';
|
||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||
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, extendEventData, useSubscriptionDisplayData } from './countdown.utils';
|
||||
import { ScheduleTime } from './CountdownSubscriptions';
|
||||
|
||||
import './SingleEventCountdown.scss';
|
||||
@@ -25,21 +26,15 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
const { data: reportData } = useReport();
|
||||
|
||||
const { offset, currentDay, actualStart, plannedStart, mode } = useExpectedStartData();
|
||||
const { totalGap, isLinkedToLoaded } = subscribedEvent;
|
||||
const expectedStart = getExpectedStart(subscribedEvent, {
|
||||
const countdownEvent = extendEventData(
|
||||
subscribedEvent,
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
plannedStart,
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
|
||||
const expectedEnd = getExpectedEnd(subscribedEvent, expectedStart, currentDay);
|
||||
|
||||
const { endedAt } = reportData[subscribedEvent.reportId ?? subscribedEvent.id] ?? { endedAt: null };
|
||||
const countdownEvent = { ...subscribedEvent, expectedStart, endedAt, expectedEnd };
|
||||
reportData,
|
||||
);
|
||||
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 +59,7 @@ export default function SingleEventCountdown({ subscribedEvent, goToEditMode }:
|
||||
}
|
||||
|
||||
interface SubscriptionStatusProps {
|
||||
event: CountdownEvent;
|
||||
event: ExtendedEntry<OntimeEvent> & { endedAt: MaybeNumber; expectedStart: number };
|
||||
}
|
||||
|
||||
function SubscriptionStatus({ event }: SubscriptionStatusProps) {
|
||||
|
||||
@@ -197,7 +197,11 @@ 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;
|
||||
expectedEnd: number;
|
||||
endedAt: MaybeNumber;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a subscription (event or group) into an event-shaped countdown target.
|
||||
@@ -262,7 +266,7 @@ export function extendEventData(
|
||||
reportData: OntimeReport,
|
||||
): CountdownEvent {
|
||||
const { totalGap, isLinkedToLoaded } = event;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
const expectedStartState = {
|
||||
currentDay,
|
||||
totalGap,
|
||||
actualStart,
|
||||
@@ -270,8 +274,9 @@ export function extendEventData(
|
||||
isLinkedToLoaded,
|
||||
offset,
|
||||
mode,
|
||||
});
|
||||
const expectedEnd = getExpectedEnd(event, expectedStart, currentDay);
|
||||
};
|
||||
const expectedStart = getExpectedStart(event, expectedStartState);
|
||||
const expectedEnd = getExpectedEnd(event, expectedStartState);
|
||||
const { endedAt } = reportData[event.reportId ?? event.id] ?? { endedAt: null };
|
||||
return { ...event, expectedStart, endedAt, expectedEnd };
|
||||
return { ...event, expectedStart, expectedEnd, 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),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -42,7 +42,7 @@
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
"postinstall": "pnpm addversion",
|
||||
"dev": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
|
||||
"dev": "cross-env PORT=4001 NODE_ENV=development tsx watch --tsconfig tsconfig.app.json ./src/index.ts",
|
||||
"dev:electron": "pnpm dev",
|
||||
"dev:inspect": "cross-env NODE_ENV=development tsx watch --tsconfig tsconfig.app.json --inspect ./src/index.ts",
|
||||
"lint": "oxlint --quiet --type-aware",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 =======================
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -71,10 +71,10 @@ Read the ontime://schema resource if you need a data model reference.
|
||||
|
||||
Steps:
|
||||
1. Call ontime_list_rundowns and identify the target rundown. If the user wants a background rundown, pass its \`rundownId\` in all entry read/write calls instead of loading it.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and avoid duplicating existing entries.
|
||||
2. Call ontime_get_rundown with the chosen \`rundownId\` to see current state and identify an \`after\` anchor if appending.
|
||||
3. Call ontime_get_timer_state. If playback is not \`stop\` and the target is the loaded rundown, explain that MCP edits affect the live rundown and ask the user to confirm before changing it. If the target is a background rundown, it can be edited without interrupting playback.
|
||||
4. Build an array of entries in order and call ontime_batch_create_entries ONCE with all of them. This is much faster than calling ontime_create_entry per item.
|
||||
5. Omit \`after\` and \`before\` on the batch call to append the new entries to the end of the rundown. Use \`after: true\` for an explicit append request, or \`before: true\` for an explicit prepend request.
|
||||
5. If the rundown already has events, pass \`after: <last event id>\` on the batch call so new events chain from the end.
|
||||
|
||||
Entry type guidance:
|
||||
- Use \`event\` for anything with a scheduled time and duration (talks, panels, breaks, meals).
|
||||
|
||||
@@ -63,11 +63,6 @@ export type BatchCreateEntryArgs = CreateEntryArgs & { children?: BatchCreateEnt
|
||||
export type UpdateEntryArgs = EntryFieldArgs & TargetRundownArgs & { id: EntryId };
|
||||
export type GroupEntriesArgs = GroupFieldArgs & TargetRundownArgs & { ids: EntryId[] };
|
||||
export type UngroupEntryArgs = TargetRundownArgs & { id: EntryId };
|
||||
type BatchCreateEntriesArgs = TargetRundownArgs & {
|
||||
entries: BatchCreateEntryArgs[];
|
||||
after?: EntryId | true;
|
||||
before?: EntryId | true;
|
||||
};
|
||||
|
||||
export function resolveTargetRundownId(args: TargetRundownArgs): string {
|
||||
return args.rundownId ?? getCurrentRundownId();
|
||||
@@ -138,24 +133,20 @@ export function assertKnownCustomFields(...customValues: Array<EntryFieldArgs['c
|
||||
/** Translates tool arguments into the payload consumed by rundown.service addEntry */
|
||||
export function toEntryPayload(args: CreateEntryArgs): EventPostPayload {
|
||||
const { type = SupportedEntry.Event, after, before } = args;
|
||||
const insertOptions = {
|
||||
...(after !== undefined ? { after } : {}),
|
||||
...(before !== undefined ? { before } : {}),
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case SupportedEntry.Delay:
|
||||
return { type: SupportedEntry.Delay, duration: args.duration, ...insertOptions };
|
||||
return { type: SupportedEntry.Delay, duration: args.duration, after, before };
|
||||
case SupportedEntry.Milestone: {
|
||||
const { cue, title, note, colour, custom } = args;
|
||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, ...insertOptions };
|
||||
return { type: SupportedEntry.Milestone, cue, title, note, colour, custom, after, before };
|
||||
}
|
||||
case SupportedEntry.Group:
|
||||
// group creation currently only accepts a title, see generateEvent in rundown.utils.ts
|
||||
return { type: SupportedEntry.Group, title: args.title, ...insertOptions };
|
||||
return { type: SupportedEntry.Group, title: args.title, after, before };
|
||||
case SupportedEntry.Event: {
|
||||
const { type: _type, rundownId: _rundownId, after: _after, before: _before, ...eventFields } = args;
|
||||
return { type: SupportedEntry.Event, ...eventFields, ...insertOptions };
|
||||
const { type: _type, rundownId: _rundownId, ...eventFields } = args;
|
||||
return { type: SupportedEntry.Event, ...eventFields };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Invalid entry type: ${String(type)}`);
|
||||
@@ -268,25 +259,20 @@ export async function ungroupEntryForMcp(args: UngroupEntryArgs) {
|
||||
return { target: getTargetMeta(rundownId), ungrouped: args.id, order: updatedRundown.order };
|
||||
}
|
||||
|
||||
export async function batchCreateEntriesForMcp(args: BatchCreateEntriesArgs) {
|
||||
const { entries = [], after, before } = args;
|
||||
export async function batchCreateEntriesForMcp(
|
||||
args: TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId },
|
||||
) {
|
||||
const { entries = [], after } = args;
|
||||
validateBatchCreateEntries(entries);
|
||||
const allEntries = flattenBatchCreateEntries(entries);
|
||||
assertKnownCustomFields(...allEntries.map((entry) => entry.custom));
|
||||
const rundownId = resolveTargetRundownId(args);
|
||||
let previousId: EntryId | undefined;
|
||||
let previousId = after;
|
||||
const created: OntimeEntry[] = [];
|
||||
|
||||
for (const entryArgs of entries) {
|
||||
const firstInsertOptions =
|
||||
created.length === 0
|
||||
? {
|
||||
...(after !== undefined ? { after } : {}),
|
||||
...(before !== undefined ? { before } : {}),
|
||||
}
|
||||
: undefined;
|
||||
// eslint-disable-next-line no-await-in-loop -- top-level entries chain after the previously created one
|
||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId, undefined, firstInsertOptions);
|
||||
const entry = await createBatchEntry(rundownId, entryArgs, previousId);
|
||||
created.push(...entry.created);
|
||||
previousId = entry.entry.id;
|
||||
}
|
||||
@@ -317,7 +303,6 @@ async function createBatchEntry(
|
||||
entryArgs: BatchCreateEntryArgs,
|
||||
previousId?: EntryId,
|
||||
parentId?: EntryId,
|
||||
firstInsertOptions?: InsertOptions,
|
||||
): Promise<{ entry: OntimeEntry; created: OntimeEntry[] }> {
|
||||
if (parentId && entryArgs.type === SupportedEntry.Group) {
|
||||
throw new Error('Cannot create a group inside another group.');
|
||||
@@ -326,9 +311,8 @@ async function createBatchEntry(
|
||||
const { children: _children, ...createArgs } = entryArgs;
|
||||
const payload = toEntryPayload(createArgs);
|
||||
const insertOptions = {
|
||||
...(previousId !== undefined ? { after: previousId } : {}),
|
||||
...(parentId !== undefined ? { parent: parentId } : {}),
|
||||
...(previousId === undefined ? firstInsertOptions : {}),
|
||||
...(previousId ? { after: previousId } : {}),
|
||||
...(parentId ? { parent: parentId } : {}),
|
||||
};
|
||||
|
||||
const createdEntry = await addEntry(rundownId, { ...payload, ...insertOptions } as EventPostPayload);
|
||||
|
||||
@@ -96,7 +96,7 @@ export const TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: 'ontime_create_entry',
|
||||
description:
|
||||
'Create a new entry. Omit after/before to append at the end, use after: true to explicitly append, use before: true to prepend, or use before/after with an entry ID to position the entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||
'Create a new entry. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Omit after/before to append at the end. For type "event" provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration. For "milestone" provide cue/title/note/colour and optional custom values using existing project custom field keys. For "delay" provide duration. For "group" provide title plus optional note/colour/custom/targetDuration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -114,14 +114,8 @@ export const TOOL_DEFINITIONS = [
|
||||
description: 'Duration in ms (events: should equal timeEnd - timeStart; delays: the schedule shift)',
|
||||
},
|
||||
targetDuration: { type: 'number', description: 'Groups only: planned length of the group in ms' },
|
||||
after: {
|
||||
type: ['string', 'boolean'],
|
||||
description: 'Insert after this entry ID, or true to append',
|
||||
},
|
||||
before: {
|
||||
type: ['string', 'boolean'],
|
||||
description: 'Insert before this entry ID, or true to prepend',
|
||||
},
|
||||
after: { type: 'string', description: 'Insert after this entry ID' },
|
||||
before: { type: 'string', description: 'Insert before this entry ID' },
|
||||
...EVENT_WRITABLE_FIELDS,
|
||||
},
|
||||
},
|
||||
@@ -224,20 +218,13 @@ export const TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: 'ontime_batch_create_entries',
|
||||
description:
|
||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; omit after/before to append the first entry at the end, use after: true to explicitly append, use before: true to prepend, or use after/before with an entry ID to position the first top-level entry. Subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||
'Create multiple entries, including groups with nested children. Omit rundownId for the currently loaded live rundown, or provide a rundownId from ontime_list_rundowns to edit a background rundown without loading it. If playback is running and rundownId is omitted or matches the loaded rundown, confirm the user intends to change the live rundown before calling. Use this for "build from agenda" flows to avoid many round trips. Entries are inserted in array order; if `after` is provided it positions the first top-level entry, subsequent top-level entries chain from the previous. A group entry may include `children`; those entries are created inside the group in array order. Groups cannot be nested. For events, provide title plus enough timing data for Ontime to infer a strategy: timeStart+duration calculates timeEnd, timeStart+timeEnd calculates duration and locks end, timeEnd+duration calculates timeStart, and all three prioritise duration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['entries'],
|
||||
properties: {
|
||||
...RUNDOWN_TARGET_FIELD,
|
||||
after: {
|
||||
type: ['string', 'boolean'],
|
||||
description: 'Insert the first entry after this entry ID, or true to append',
|
||||
},
|
||||
before: {
|
||||
type: ['string', 'boolean'],
|
||||
description: 'Insert the first entry before this entry ID, or true to prepend',
|
||||
},
|
||||
after: { type: 'string', description: 'Insert the first entry after this entry ID' },
|
||||
entries: {
|
||||
type: 'array',
|
||||
description: 'Array of entries to create, in desired order',
|
||||
@@ -601,13 +588,7 @@ const TOOL_HANDLERS: Record<ToolName, (args: Record<string, unknown>) => Promise
|
||||
|
||||
ontime_batch_create_entries: async (args) => {
|
||||
return ok(
|
||||
await batchCreateEntriesForMcp(
|
||||
args as TargetRundownArgs & {
|
||||
entries: BatchCreateEntryArgs[];
|
||||
after?: EntryId | true;
|
||||
before?: EntryId | true;
|
||||
},
|
||||
),
|
||||
await batchCreateEntriesForMcp(args as TargetRundownArgs & { entries: BatchCreateEntryArgs[]; after?: EntryId }),
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -117,9 +117,8 @@ app.use(`${prefix}/external`, (req, res) => {
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
|
||||
|
||||
// Serve legacy timer for old browsers that don't support modern JS
|
||||
// dotfiles must be allowed since the install path can contain dot directories (eg AppImage mounts in /tmp/.mount_*)
|
||||
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
|
||||
res.sendFile(srcFiles.timerLegacy, { dotfiles: 'allow' });
|
||||
res.sendFile(srcFiles.timerLegacy);
|
||||
});
|
||||
|
||||
// Base route for static files
|
||||
|
||||
@@ -7,11 +7,21 @@ import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||
import { loadRoll } from '../rollUtils.js';
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('getExpectedFinish()', () => {
|
||||
expect(calculatedFinish).toBe(10);
|
||||
});
|
||||
describe('on timers of type time-to-end', () => {
|
||||
it('finish time is as schedule + added time', () => {
|
||||
it('finish time is the fixed end, ignoring added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 30,
|
||||
@@ -242,32 +242,57 @@ describe('getExpectedFinish()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored: added time surfaces as offset, it does not move the finish
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(40);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
});
|
||||
it('handles events that finish the day after', () => {
|
||||
|
||||
it('finish time is the fixed end, ignoring pauses', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 600000, // 00:10:00
|
||||
timeEnd: 30,
|
||||
countToEnd: true,
|
||||
},
|
||||
clock: 25,
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: dayInMs,
|
||||
startedAt: 10,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: 20, // paused 5 ago - wall clock keeps approaching the fixed end
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
expect(calculatedFinish).toBe(30);
|
||||
});
|
||||
|
||||
it('returns the start time for count to end times which started late', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 20 * MILLIS_PER_HOUR, // 20:00:00
|
||||
timeEnd: 21 * MILLIS_PER_MINUTE, // 21:00:00
|
||||
countToEnd: true,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
startedAt: 79200000, // 22:00:00
|
||||
startedAt: 22 * MILLIS_PER_HOUR, // 22:00:00 <--------------
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
actualStart: 20 * MILLIS_PER_HOUR, // 20:00:00
|
||||
plannedEnd: 21 * MILLIS_PER_HOUR, // 21:00:00
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const calculatedFinish = getExpectedFinish(state);
|
||||
// expected finish is not a duration but a point in time
|
||||
expect(calculatedFinish).toBe(600000);
|
||||
// timeEnd is numerically before startedAt for overnight events, so expectedFinish is clamped to startedAt
|
||||
expect(calculatedFinish).toBe(22 * MILLIS_PER_HOUR);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -451,7 +476,7 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('current time is the time to end + added time', () => {
|
||||
it('current time is the time to end, ignoring added time', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeEnd: 100,
|
||||
@@ -472,8 +497,9 @@ describe('getCurrent()', () => {
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
// counts to the fixed end; added time surfaces as offset, not extra countdown
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(77);
|
||||
expect(current).toBe(70);
|
||||
});
|
||||
|
||||
it('handles events that finish the day after', () => {
|
||||
@@ -482,6 +508,7 @@ describe('getCurrent()', () => {
|
||||
timeStart: 79200000, // 22:00:00
|
||||
timeEnd: 600000, // 00:10:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 79500000, // 22:05:00
|
||||
timer: {
|
||||
@@ -492,6 +519,7 @@ describe('getCurrent()', () => {
|
||||
rundown: {
|
||||
actualStart: 79200000,
|
||||
plannedEnd: 600000,
|
||||
currentDay: 0,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
@@ -503,6 +531,35 @@ describe('getCurrent()', () => {
|
||||
expect(current).toBe(dayInMs - 79500000 + 600000);
|
||||
});
|
||||
|
||||
it('handles overnight count-to-end after midnight', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
timeStart: 23 * MILLIS_PER_HOUR, // 23:00:00
|
||||
timeEnd: 1 * MILLIS_PER_HOUR, // 01:00:00
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
},
|
||||
clock: 30 * MILLIS_PER_MINUTE, // 00:30:00 on day 1
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
duration: Infinity,
|
||||
startedAt: 23 * MILLIS_PER_HOUR,
|
||||
},
|
||||
rundown: {
|
||||
actualStart: 23 * MILLIS_PER_HOUR,
|
||||
plannedEnd: 1 * MILLIS_PER_HOUR,
|
||||
currentDay: 1,
|
||||
},
|
||||
_timer: {
|
||||
pausedAt: null,
|
||||
hasFinished: false,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const current = getCurrent(state);
|
||||
expect(current).toBe(30 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('handles events that were started late', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40 <--- starting 16 min after the scheduled end
|
||||
@@ -1073,7 +1130,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(absolute).toBe(0);
|
||||
});
|
||||
|
||||
it('with time-to-end, offset is the overtime', () => {
|
||||
it('with time-to-end, offset combines overtime and added time', () => {
|
||||
const state = {
|
||||
clock: 82000000, // 22:46:40
|
||||
eventNow: {
|
||||
@@ -1126,7 +1183,45 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(400000 - 200000); // <--- offset is always the overtime + added time
|
||||
// overtime (400000) plus the operator's added time (-200000)
|
||||
expect(absolute).toBe(200000);
|
||||
});
|
||||
|
||||
it('with time-to-end, added time surfaces as offset', () => {
|
||||
const state = {
|
||||
clock: 80000000, // 22:13:20 - before the scheduled end, not in overtime
|
||||
eventNow: {
|
||||
id: 'd6a2ce',
|
||||
timeStart: 77400000, // 21:30:00
|
||||
timeEnd: 81000000, // 22:30:00
|
||||
duration: 3600000, // 01:00:00
|
||||
timeStrategy: TimeStrategy.LockEnd,
|
||||
countToEnd: true,
|
||||
dayOffset: 0,
|
||||
delay: 0,
|
||||
},
|
||||
rundown: {
|
||||
plannedStart: 77400000, // 21:30:00
|
||||
plannedEnd: 81000000, // 22:30:00
|
||||
actualStart: 78000000, // 21:40:00
|
||||
currentDay: 0,
|
||||
},
|
||||
offset: {
|
||||
absolute: 0,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 300000, // operator added 5 minutes
|
||||
current: 1000000, // still counting down, no overtime
|
||||
duration: 3600000,
|
||||
startedAt: 78000000,
|
||||
},
|
||||
_startDayOffset: 0,
|
||||
_timer: { pausedAt: null },
|
||||
} as RuntimeState;
|
||||
|
||||
// the end is anchored, so the added 5 minutes shows up purely as offset
|
||||
const { absolute } = getRuntimeOffset(state);
|
||||
expect(absolute).toBe(300000);
|
||||
});
|
||||
|
||||
it('handles time-to-end started after the end time', () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Expose errors where we reach invalid states
|
||||
* used mostly in shouldCrashDev patterns
|
||||
*/
|
||||
export class InvalidStateError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -165,13 +165,6 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the currently loaded event, or null if none is loaded
|
||||
*/
|
||||
public getLoadedEventId(): EntryId | null {
|
||||
return runtimeState.getState().eventNow?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the underlying data has changed,
|
||||
* we check if the change affects the runtime
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Day, MaybeNumber, TimeOfDay, TimerPhase } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
|
||||
|
||||
import type { RuntimeState } from '../stores/runtimeState.js';
|
||||
import { InvalidStateError } from './generic.errors.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
@@ -24,35 +25,35 @@ export function hasCrossedMidnight(previous: TimeOfDay, current: TimeOfDay): boo
|
||||
* @returns {number | null} new current time or null if nothing is running
|
||||
*/
|
||||
export function getExpectedFinish(state: RuntimeState): MaybeNumber {
|
||||
const { startedAt, duration, addedTime } = state.timer;
|
||||
|
||||
if (state.eventNow === null) {
|
||||
// if there is a loaded event it must have started
|
||||
// either way, we have no expected finish if nothing is playing
|
||||
if (state.eventNow === null || state.timer.startedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { countToEnd, timeEnd } = state.eventNow;
|
||||
const { pausedAt } = state._timer;
|
||||
const { clock } = state;
|
||||
|
||||
if (startedAt === null) {
|
||||
return null;
|
||||
if (state.eventNow.countToEnd) {
|
||||
// count to end events are anchored to their fixed end: added time and pauses
|
||||
// do not move the end, they surface as offset instead (see getRuntimeOffset)
|
||||
return Math.max(state.eventNow.timeEnd, state.timer.startedAt);
|
||||
}
|
||||
|
||||
const pausedTime = pausedAt != null ? clock - pausedAt : 0;
|
||||
const pausedTime = state._timer.pausedAt != null ? state.clock - state._timer.pausedAt : 0;
|
||||
|
||||
if (countToEnd) {
|
||||
return timeEnd + addedTime + pausedTime;
|
||||
DEV: {
|
||||
if (state.timer.duration === null) {
|
||||
throw new InvalidStateError('a running timer cannot have null duration');
|
||||
}
|
||||
}
|
||||
|
||||
// handle events that finish the day after
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- duration exists if ther eis a timer
|
||||
const expectedFinish = startedAt + duration! + addedTime + pausedTime;
|
||||
const expectedFinish = state.timer.startedAt + state.timer.duration + state.timer.addedTime + pausedTime;
|
||||
if (expectedFinish > dayInMs) {
|
||||
return expectedFinish - dayInMs;
|
||||
}
|
||||
|
||||
// an event cannot finish before it started (user added too much negative time)
|
||||
return Math.max(expectedFinish, startedAt);
|
||||
return Math.max(expectedFinish, state.timer.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +75,12 @@ export function getCurrent(state: RuntimeState): number {
|
||||
const { clock } = state;
|
||||
|
||||
if (countToEnd) {
|
||||
const isEventOverMidnight = timeStart > timeEnd;
|
||||
const correctDay = isEventOverMidnight ? dayInMs : 0;
|
||||
return correctDay - clock + timeEnd + addedTime;
|
||||
// count to end runs to its fixed end, so added time does not stretch the countdown
|
||||
const dayOffset = state.eventNow.dayOffset ?? 0;
|
||||
const currentDay =
|
||||
state.rundown.currentDay ?? (timeStart > timeEnd && clock <= timeEnd ? dayOffset + 1 : dayOffset);
|
||||
const endDay = timeStart > timeEnd ? dayOffset + 1 : dayOffset;
|
||||
return timeEnd + endDay * dayInMs - (clock + currentDay * dayInMs);
|
||||
}
|
||||
|
||||
if (startedAt === null) {
|
||||
@@ -180,7 +184,8 @@ export function getRuntimeOffset(state: RuntimeState): { absolute: number; relat
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||
|
||||
// absolute offset is difference between schedule and playback time
|
||||
// in case of count to end, the absolute offset is overtime and added time
|
||||
// count to end is anchored to its fixed end, so it absorbs the late start (eventStartOffset)
|
||||
// and the pause (already reflected in overtime); added time surfaces here as offset
|
||||
const absolute = countToEnd ? overtime + addedTime : eventStartOffset + overtime + pausedTime + addedTime;
|
||||
|
||||
// the relative offset is the same as the absolute but adjusted relative to the actual start time
|
||||
|
||||
@@ -56,18 +56,21 @@ const mockState = {
|
||||
} as RuntimeState;
|
||||
|
||||
vi.mock('../../classes/data-provider/DataProvider.js', () => {
|
||||
let automation = {
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
};
|
||||
|
||||
return {
|
||||
getDataProvider: vi.fn().mockImplementation(() => {
|
||||
return {
|
||||
getAutomation: vi.fn().mockImplementation(() => automation),
|
||||
setCustomFields: vi.fn().mockImplementation((newData) => newData),
|
||||
setRundown: vi.fn().mockImplementation((newData) => newData),
|
||||
getAutomation: vi.fn().mockReturnValue({
|
||||
enabledAutomations: false,
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 0,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
}),
|
||||
setAutomation: vi.fn().mockImplementation((newData) => (automation = newData)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -325,6 +328,121 @@ describe('mutation on runtimeState', () => {
|
||||
expect(newState.offset.expectedRundownEnd).toBeNull();
|
||||
});
|
||||
|
||||
test('a countToEnd last event absorbs overtime into its fixed rundown end', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 11 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
parent: null,
|
||||
},
|
||||
event2: {
|
||||
...mockEvent,
|
||||
id: 'event2',
|
||||
timeStart: 11 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1', 'event2'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start event1 five minutes behind schedule
|
||||
vi.setSystemTime('jan 1 10:05');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
const newState = getState();
|
||||
expect(newState.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
// without countToEnd the rundown would end at 12h + 5min, but the countToEnd
|
||||
// event absorbs the overtime so the rundown is still expected to end at 12h
|
||||
expect(newState.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('adding time to a running countToEnd event surfaces as offset and end remains fixed', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
// start on time
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
let state = getState();
|
||||
expect(state.offset.absolute).toBe(0);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
|
||||
// operator adds 5 minutes
|
||||
addTime(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
state = getState();
|
||||
// the end cant move, so the added time is added to the offset
|
||||
expect(state.offset.absolute).toBe(5 * MILLIS_PER_MINUTE);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
expect(state.offset.expectedRundownEnd).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('subtracting more than the remaining time does not finish a countToEnd event', async () => {
|
||||
const entries = {
|
||||
event1: {
|
||||
...mockEvent,
|
||||
id: 'event1',
|
||||
timeStart: 10 * MILLIS_PER_HOUR,
|
||||
timeEnd: 12 * MILLIS_PER_HOUR,
|
||||
duration: 2 * MILLIS_PER_HOUR,
|
||||
countToEnd: true,
|
||||
parent: null,
|
||||
},
|
||||
};
|
||||
const mockRundown = makeRundown({ entries, order: ['event1'] });
|
||||
|
||||
await initRundown(mockRundown, {});
|
||||
vi.runAllTimers();
|
||||
|
||||
const { metadata, rundown } = rundownCache.get();
|
||||
|
||||
vi.setSystemTime('jan 1 10:00');
|
||||
load(entries.event1, rundown, metadata);
|
||||
start();
|
||||
update();
|
||||
|
||||
// removing longer than the remaining time cannot finish the event since the end is fixed
|
||||
addTime(-3 * MILLIS_PER_HOUR);
|
||||
update();
|
||||
|
||||
const state = getState();
|
||||
expect(state.timer.playback).toBe(Playback.Play);
|
||||
expect(state.timer.expectedFinish).toBe(12 * MILLIS_PER_HOUR);
|
||||
});
|
||||
|
||||
test('resume restores currentDay from restore point', async () => {
|
||||
clearState();
|
||||
const mockRundown = makeRundown({
|
||||
@@ -1037,166 +1155,31 @@ describe('loadGroupFlagAndEnd()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('countToEnd entries decouple the link chain for subsequent events', () => {
|
||||
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||
// Event 1: countToEnd=true → breaks the link chain
|
||||
// Event 2: linkStart=true → unlinked (chain broken by event 1)
|
||||
// Event 3: flag event → also unlinked
|
||||
test('a countToEnd event breaks the link chain for the events that follow it', () => {
|
||||
// chain: A (loaded) -> B (countToEnd, flagged) -> C (linked, last event)
|
||||
// the chain stays intact up to and including B, but breaks for C since it follows a countToEnd event
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||
0: makeOntimeEvent({
|
||||
id: '0',
|
||||
parent: 'group',
|
||||
timeStart: 0,
|
||||
duration: 3600000,
|
||||
countToEnd: false,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
1: makeOntimeEvent({
|
||||
id: '1',
|
||||
parent: 'group',
|
||||
timeStart: 3600000,
|
||||
duration: 3600000,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
2: makeOntimeEvent({
|
||||
id: '2',
|
||||
parent: 'group',
|
||||
timeStart: 7200000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
3: makeOntimeEvent({
|
||||
id: '3',
|
||||
parent: null,
|
||||
timeStart: 10800000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
A: makeOntimeEvent({ id: 'A', parent: null, linkStart: false, countToEnd: false, gap: 0 }),
|
||||
B: makeOntimeEvent({ id: 'B', parent: null, linkStart: true, countToEnd: true, gap: 0, flag: true }),
|
||||
C: makeOntimeEvent({ id: 'C', parent: null, linkStart: true, countToEnd: false, gap: 0 }),
|
||||
},
|
||||
order: ['group', '0', '1', '2', '3'],
|
||||
order: ['A', 'B', 'C'],
|
||||
});
|
||||
|
||||
const state = {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[0],
|
||||
eventNow: rundown.entries.A,
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||
const metadata = { playableEventOrder: ['A', 'B', 'C'], flags: ['B'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
|
||||
// _group is the last event in the group (event 2)
|
||||
// isLinkedToLoaded is false because event 1 (between loaded and group end) has countToEnd=true
|
||||
// accumulatedGap includes event 1's duration carried forward to event 2
|
||||
// (the countToEnd duration is applied to the next event, not the countToEnd event itself)
|
||||
expect(state._group).toMatchObject({
|
||||
event: rundown.entries[2],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _flag (event 3): isLinkedToLoaded is false because chain was broken by event 1
|
||||
// accumulatedGap is still 3600000 because event 2 is not countToEnd,
|
||||
// so previousWasCountToEnd is null and no further duration is carried forward
|
||||
expect(state._flag).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _end (event 3): also unlinked because the chain was broken at event 1
|
||||
expect(state._end).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('countToEnd in the middle of the chain breaks links for downstream events', () => {
|
||||
// Event 0 (loaded): no countToEnd, linkStart=true
|
||||
// Event 1: linkStart=true → linked to loaded
|
||||
// Event 2: countToEnd=true → breaks the chain (also last in group)
|
||||
// Event 3: linkStart=true → unlinked (chain broken by event 2)
|
||||
const rundown = makeRundown({
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['0', '1', '2'] }),
|
||||
0: makeOntimeEvent({
|
||||
id: '0',
|
||||
parent: 'group',
|
||||
timeStart: 0,
|
||||
duration: 3600000,
|
||||
countToEnd: false,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
1: makeOntimeEvent({
|
||||
id: '1',
|
||||
parent: 'group',
|
||||
timeStart: 3600000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
2: makeOntimeEvent({
|
||||
id: '2',
|
||||
parent: 'group',
|
||||
timeStart: 7200000,
|
||||
duration: 3600000,
|
||||
countToEnd: true,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
3: makeOntimeEvent({
|
||||
id: '3',
|
||||
parent: null,
|
||||
timeStart: 10800000,
|
||||
duration: 3600000,
|
||||
linkStart: true,
|
||||
gap: 0,
|
||||
} as any),
|
||||
},
|
||||
order: ['group', '0', '1', '2', '3'],
|
||||
});
|
||||
|
||||
const state = {
|
||||
groupNow: null,
|
||||
eventNow: rundown.entries[0],
|
||||
rundown: { actualGroupStart: null },
|
||||
} as RuntimeState;
|
||||
|
||||
const metadata = { playableEventOrder: ['0', '1', '2', '3'], flags: ['3'] } as RundownMetadata;
|
||||
|
||||
loadGroupFlagAndEnd(rundown, metadata, 0, state);
|
||||
|
||||
// _group is the last event in the group (event 2)
|
||||
// isLinkedToLoaded is true because no preceding event had countToEnd
|
||||
// accumulatedGap is 0 because no preceding event was countToEnd
|
||||
// (the countToEnd duration is carried forward to the next event, not added to the countToEnd event itself)
|
||||
expect(state._group).toMatchObject({
|
||||
event: rundown.entries[2],
|
||||
isLinkedToLoaded: true,
|
||||
accumulatedGap: 0,
|
||||
});
|
||||
|
||||
// _flag (event 3): isLinkedToLoaded is false because chain was broken by count-to-end event 2
|
||||
// accumulatedGap includes event 2's duration carried forward
|
||||
expect(state._flag).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
accumulatedGap: 3600000,
|
||||
});
|
||||
|
||||
// _end (event 3): unlinked because event 2 has countToEnd=true
|
||||
expect(state._end).toMatchObject({
|
||||
event: rundown.entries[3],
|
||||
isLinkedToLoaded: false,
|
||||
});
|
||||
// the flag (B) is still part of the chain
|
||||
expect(state._flag).toMatchObject({ event: rundown.entries.B, isLinkedToLoaded: true });
|
||||
// the rundown end (C) follows the countToEnd event, so the chain is broken
|
||||
expect(state._end).toMatchObject({ event: rundown.entries.C, isLinkedToLoaded: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
hasCrossedMidnight,
|
||||
} from '../services/timerUtils.js';
|
||||
import { timerConfig } from '../setup/config.js';
|
||||
import { shouldCrashDev } from '../utils/development.js';
|
||||
|
||||
type ExpectedMetadata = {
|
||||
event: OntimeEvent;
|
||||
@@ -510,23 +511,31 @@ export function addTime(amount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
if (runtimeState.eventNow?.countToEnd) {
|
||||
// count to end is anchored to its fixed end: added time cannot move the end or finish
|
||||
// the event early, it only surfaces as offset. `current` is derived from the wall clock
|
||||
// by the update loop, so we must not bump it here.
|
||||
runtimeState.timer.addedTime += amount;
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
|
||||
if (willGoNegative && !runtimeState._timer.hasFinished) {
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = timeCore.timeOfDayNow();
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
runtimeState._timer.hasFinished = false;
|
||||
}
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
runtimeState.timer.elapsed = getElapsed(runtimeState);
|
||||
|
||||
// update runtime delays: over - under
|
||||
@@ -841,9 +850,11 @@ function getExpectedTimes(state = runtimeState) {
|
||||
|
||||
if (state.groupNow) {
|
||||
const { _group } = state;
|
||||
if (_group !== null) {
|
||||
const { event: lastEvent, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
const lastEventExpectedStart = getExpectedStart(lastEvent, {
|
||||
DEV: shouldCrashDev(_group === null, 'groupNow is set but _group is null');
|
||||
|
||||
if (_group) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _group;
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -852,15 +863,16 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedGroupEnd = getExpectedEnd(lastEvent, lastEventExpectedStart, state.rundown.currentDay!);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.eventFlag) {
|
||||
const { _flag } = state;
|
||||
DEV: shouldCrashDev(_flag === null, 'eventFlag is set but _flag is null');
|
||||
|
||||
if (_flag) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = _flag;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
state.offset.expectedFlagStart = getExpectedStart(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -869,13 +881,13 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedFlagStart = expectedStart;
|
||||
}
|
||||
}
|
||||
|
||||
if (state._end) {
|
||||
const { event, accumulatedGap, isLinkedToLoaded } = state._end;
|
||||
const expectedStart = getExpectedStart(event, {
|
||||
|
||||
state.offset.expectedRundownEnd = getExpectedEnd(event, {
|
||||
currentDay: state.rundown.currentDay!,
|
||||
totalGap: accumulatedGap,
|
||||
isLinkedToLoaded,
|
||||
@@ -884,7 +896,6 @@ function getExpectedTimes(state = runtimeState) {
|
||||
plannedStart,
|
||||
actualStart,
|
||||
});
|
||||
state.offset.expectedRundownEnd = getExpectedEnd(event, expectedStart, state.rundown.currentDay!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,7 +938,8 @@ export function loadGroupFlagAndEnd(
|
||||
|
||||
let accumulatedGap = 0;
|
||||
let isLinkedToLoaded = true;
|
||||
let previousWasCountToEnd: Maybe<number> = null;
|
||||
// a countToEnd event absorbs overtime and breaks the chain
|
||||
let previousWasCountToEnd = false;
|
||||
|
||||
for (let idx = currentIndex; idx < playableEventOrder.length; idx++) {
|
||||
const entry = entries[playableEventOrder[idx]];
|
||||
@@ -935,23 +947,8 @@ export function loadGroupFlagAndEnd(
|
||||
if (isOntimeEvent(entry)) {
|
||||
if (idx !== currentIndex) {
|
||||
// we only accumulate data after the loaded event
|
||||
|
||||
if (previousWasCountToEnd !== null) {
|
||||
/** previous event was countToEnd: add its duration as a positive gap (it "gives back" time downstream)
|
||||
* and break the link to the loaded event since countToEnd events reset the schedule
|
||||
*/
|
||||
accumulatedGap += entry.gap + previousWasCountToEnd;
|
||||
isLinkedToLoaded = false;
|
||||
} else {
|
||||
accumulatedGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart;
|
||||
}
|
||||
|
||||
if (entry.countToEnd) {
|
||||
previousWasCountToEnd = entry.duration;
|
||||
} else {
|
||||
previousWasCountToEnd = null;
|
||||
}
|
||||
accumulatedGap += entry.gap;
|
||||
isLinkedToLoaded = isLinkedToLoaded && entry.linkStart && !previousWasCountToEnd;
|
||||
|
||||
// and the loaded event is not allowed to be the next flag
|
||||
if (!foundFlag && metadata.flags.includes(entry.id)) {
|
||||
@@ -965,6 +962,8 @@ export function loadGroupFlagAndEnd(
|
||||
foundGroupEnd = true;
|
||||
state._group = { event: lastEventInGroup, isLinkedToLoaded, accumulatedGap };
|
||||
}
|
||||
|
||||
previousWasCountToEnd = entry.countToEnd;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ test('imports spreadsheet and applies imported rundown to editor', async ({ page
|
||||
await page.getByRole('button', { name: 'Preview import' }).click();
|
||||
await page.getByRole('button', { name: 'Apply import' }).click();
|
||||
await expect(page.getByText('Import complete')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Open editor' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Import another' }).click();
|
||||
await expect(page.getByText('Spreadsheet data applied.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Reset flow' }).click();
|
||||
|
||||
// verify the data in the rundown
|
||||
await page.getByRole('button', { name: 'Close settings' }).scrollIntoViewIfNeeded();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.11.0",
|
||||
"version": "4.10.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.11.0",
|
||||
"version": "4.10.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { MaybeNumber } from '../../utils/utils.type.js';
|
||||
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
|
||||
|
||||
export type InsertOptions = {
|
||||
after?: EntryId | true;
|
||||
before?: EntryId | true;
|
||||
after?: EntryId;
|
||||
before?: EntryId;
|
||||
};
|
||||
|
||||
export type EventPostPayload = Partial<OntimeEntry> & InsertOptions;
|
||||
|
||||
@@ -18,44 +18,3 @@ export type SpreadsheetPreviewResponse = {
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
|
||||
/**
|
||||
* How elements matched by id should be reconciled when importing into an existing rundown
|
||||
* - override: imported data replaces the whole matched element
|
||||
* - merge: imported data updates only the fields the spreadsheet supplied, keeping the rest
|
||||
*/
|
||||
export type RundownImportMergeStrategy = 'override' | 'merge';
|
||||
|
||||
/**
|
||||
* What an import does with the data
|
||||
* - override / merge apply the import onto an existing rundown (reconciling matched elements)
|
||||
* - new creates a fresh rundown from the import
|
||||
*/
|
||||
export type RundownImportMode = RundownImportMergeStrategy | 'new';
|
||||
|
||||
/**
|
||||
* The fields an import supplies, i.e. the columns the spreadsheet maps.
|
||||
* This is the complete description of what the incoming data provides, so a merge can apply exactly
|
||||
* these fields onto a matched event and keep everything else (e.g. automations) untouched.
|
||||
*/
|
||||
export type ImportedFields = {
|
||||
/** mapped OntimeEvent fields (the import-map keys are OntimeEvent field names) */
|
||||
event: string[];
|
||||
/** mapped custom field keys */
|
||||
custom: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for the rundown import endpoint
|
||||
* - override / merge apply the import onto the target rundown (targetRundownId required)
|
||||
* - new creates a fresh rundown from the import
|
||||
*/
|
||||
export type RundownImportPayload = {
|
||||
mode: RundownImportMode;
|
||||
/** required when mode is 'override' or 'merge' */
|
||||
targetRundownId?: string;
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
/** the fields the spreadsheet supplies; used by 'merge' to patch only those on a matched event */
|
||||
providedFields?: ImportedFields;
|
||||
};
|
||||
|
||||
@@ -111,9 +111,9 @@ export type OntimeAction =
|
||||
text?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
// TODO: when setting a secondary source of type secondary we could specify a value to it
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: OntimeMessageSecondary;
|
||||
secondarySource?: SecondarySource;
|
||||
text?: string;
|
||||
secondarySource: SecondarySource;
|
||||
};
|
||||
|
||||
@@ -97,10 +97,6 @@ export type {
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
SpreadsheetPreviewResponse,
|
||||
RundownImportMergeStrategy,
|
||||
RundownImportMode,
|
||||
RundownImportPayload,
|
||||
ImportedFields,
|
||||
} from './api/spreadsheet/Spreadsheet.type.js';
|
||||
|
||||
// web socket
|
||||
|
||||
@@ -99,6 +99,36 @@ describe('getExpectedStart()', () => {
|
||||
expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: false })).toBe(110); // <-- when gap is not enough to compensate for the running behind it absorbs at much as possible
|
||||
// expect(getExpectedStart(testEvent, { ...testState, isLinkedToLoaded: true })).toBe(70); This should not be possible
|
||||
});
|
||||
|
||||
test('negative delay cannot move a day 0 event before the rundown start', () => {
|
||||
const testState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
offset: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 0 as Day }, testState)).toBe(0);
|
||||
expect(getExpectedStart({ timeStart: 10, delay: -5, dayOffset: 0 as Day }, testState)).toBe(5);
|
||||
});
|
||||
|
||||
test('negative delay can move a later-day event back to the previous day', () => {
|
||||
const testState = {
|
||||
currentDay: 0,
|
||||
totalGap: 0,
|
||||
offset: 0,
|
||||
mode: OffsetMode.Absolute,
|
||||
actualStart: null,
|
||||
plannedStart: null,
|
||||
isLinkedToLoaded: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart({ timeStart: 0, delay: -5, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 5);
|
||||
expect(getExpectedStart({ timeStart: 10, delay: -20, dayOffset: 1 as Day }, testState)).toBe(dayInMs - 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relative offset mode', () => {
|
||||
@@ -336,12 +366,9 @@ describe('getExpectedEnd()', () => {
|
||||
};
|
||||
|
||||
// on schedule
|
||||
const expectedStart0 = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart0, baseState.currentDay)).toBe(150);
|
||||
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(150);
|
||||
// running 20 behind pushes the end out
|
||||
const expectedStart20 = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart20, baseState.currentDay)).toBe(170);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(170);
|
||||
});
|
||||
|
||||
test('a countToEnd event pins to the planned end while in overtime', () => {
|
||||
@@ -354,8 +381,7 @@ describe('getExpectedEnd()', () => {
|
||||
};
|
||||
|
||||
// overtime would otherwise push the end to 170, but countToEnd absorbs it and pins to 150
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 20 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 20 })).toBe(150);
|
||||
});
|
||||
|
||||
test('an overnight countToEnd event returns a normalised end', () => {
|
||||
@@ -370,8 +396,7 @@ describe('getExpectedEnd()', () => {
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(timeStart + duration);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(timeStart + duration);
|
||||
});
|
||||
|
||||
test('a countToEnd event ignores upstream delays and stays pinned to its fixed end', () => {
|
||||
@@ -383,12 +408,9 @@ describe('getExpectedEnd()', () => {
|
||||
};
|
||||
|
||||
// events shift their schedule based on delay...
|
||||
const expectedStart = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStart, baseState.currentDay)).toBe(170);
|
||||
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, { ...baseState, offset: 0 })).toBe(170);
|
||||
// ... but count to end events stay pinned to the scheduled end
|
||||
const expectedStartCountToEnd = getExpectedStart({ ...testEvent }, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, expectedStartCountToEnd, baseState.currentDay)).toBe(150);
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: true }, { ...baseState, offset: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event drifts when a delay pushes its start past the fixed end', () => {
|
||||
@@ -402,8 +424,7 @@ describe('getExpectedEnd()', () => {
|
||||
|
||||
// the delayed start (160) is past the fixed end (150), so the event can no longer
|
||||
// finish on time and the end follows the compromised start
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(160);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, offset: 0 })).toBe(160);
|
||||
});
|
||||
|
||||
test('a countToEnd event on a later day keeps the day offset on the end', () => {
|
||||
@@ -417,15 +438,22 @@ describe('getExpectedEnd()', () => {
|
||||
|
||||
// the scheduled end must include the day offset (timeStart + dayInMs + duration),
|
||||
// not collapse to the day-shifted start
|
||||
const expectedStart = getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 });
|
||||
expect(getExpectedEnd(testEvent, expectedStart, baseState.currentDay)).toBe(150 + dayInMs);
|
||||
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(150 + dayInMs);
|
||||
// when the running event is already on the same day, no extra day is added
|
||||
const expectedStartSameDay = getExpectedStart(
|
||||
{ ...testEvent, dayOffset: 0 as Day },
|
||||
{ ...baseState, currentDay: 0, offset: 0 },
|
||||
);
|
||||
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, expectedStartSameDay, baseState.currentDay)).toBe(150);
|
||||
expect(getExpectedEnd({ ...testEvent, dayOffset: 0 as Day }, { ...baseState, currentDay: 0, offset: 0 })).toBe(150);
|
||||
});
|
||||
|
||||
test('a countToEnd event with negative delay can start on the previous day but keeps its fixed end', () => {
|
||||
const testEvent = {
|
||||
timeStart: 0,
|
||||
duration: 50,
|
||||
delay: -10,
|
||||
dayOffset: 1 as Day,
|
||||
countToEnd: true,
|
||||
};
|
||||
|
||||
expect(getExpectedStart(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs - 10);
|
||||
expect(getExpectedEnd(testEvent, { ...baseState, currentDay: 0, offset: 0 })).toBe(dayInMs + 50);
|
||||
});
|
||||
|
||||
test('a countToEnd event is anchored to its wall-clock end in relative mode', () => {
|
||||
@@ -446,13 +474,8 @@ describe('getExpectedEnd()', () => {
|
||||
};
|
||||
|
||||
// a regular event in the same state is shifted by the relative-start offset to 180
|
||||
const expectedStartRegular = getExpectedStart({ ...testEvent }, relativeState);
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, expectedStartRegular, relativeState.currentDay)).toBe(
|
||||
180,
|
||||
);
|
||||
|
||||
expect(getExpectedEnd({ ...testEvent, countToEnd: false }, relativeState)).toBe(180);
|
||||
// the countToEnd event stays pinned to its wall-clock end (150), not shifted
|
||||
const expectedStartCountToEnd = getExpectedStart(testEvent, relativeState);
|
||||
expect(getExpectedEnd(testEvent, expectedStartCountToEnd, relativeState.currentDay)).toBe(150);
|
||||
expect(getExpectedEnd(testEvent, relativeState)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,28 +3,28 @@ import { OffsetMode } from 'ontime-types';
|
||||
|
||||
import { dayInMs } from './conversionUtils.js';
|
||||
|
||||
/**
|
||||
* Runtime context shared by the expected start/end calculations
|
||||
*/
|
||||
type ExpectedTimesState = {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number; // accumulated gap from the current event
|
||||
isLinkedToLoaded: boolean; // is this event part of a chain linking back to the loaded event
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
};
|
||||
|
||||
export function getExpectedStart(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay'>,
|
||||
state: {
|
||||
currentDay: number; // the current day from the rundown
|
||||
totalGap: number;
|
||||
isLinkedToLoaded: boolean;
|
||||
offset: number;
|
||||
mode: OffsetMode;
|
||||
actualStart: MaybeNumber;
|
||||
plannedStart: MaybeNumber;
|
||||
},
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
const { timeStart, dayOffset, delay } = event;
|
||||
const { currentDay, totalGap, isLinkedToLoaded, offset, mode, actualStart, plannedStart } = state;
|
||||
|
||||
//How many days from the currently running event to this one
|
||||
const relativeDayOffset = dayOffset - currentDay;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
|
||||
//The normalised start time of this event relative to the currently running event
|
||||
const normalisedTimeStart = delayedStart + relativeDayOffset * dayInMs;
|
||||
const absoluteDelayedStart = Math.max(0, dayOffset * dayInMs + timeStart + delay);
|
||||
const normalisedTimeStart = absoluteDelayedStart - currentDay * dayInMs;
|
||||
|
||||
let relativeStartOffset = 0;
|
||||
|
||||
@@ -53,18 +53,18 @@ export function getExpectedStart(
|
||||
}
|
||||
|
||||
export function getExpectedEnd(
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'duration' | 'countToEnd'>,
|
||||
expectedStart: number,
|
||||
currentRuntimeDay: number,
|
||||
event: Pick<OntimeEvent, 'timeStart' | 'dayOffset' | 'delay' | 'duration' | 'countToEnd'>,
|
||||
state: ExpectedTimesState,
|
||||
): number {
|
||||
// expected start includes the delay and any offset compensation
|
||||
const expectedStart = getExpectedStart(event, state);
|
||||
|
||||
/**
|
||||
* Count to end events are a special case
|
||||
* - the end time is always the wall clock
|
||||
*/
|
||||
if (event.countToEnd) {
|
||||
// account for day offset
|
||||
const relativeDayOffset = event.dayOffset - currentRuntimeDay;
|
||||
const plannedEnd = event.timeStart + event.duration + relativeDayOffset * dayInMs;
|
||||
const plannedEnd = event.dayOffset * dayInMs + event.timeStart + event.duration - state.currentDay * dayInMs;
|
||||
|
||||
// count to end should finish on the planned time or on start
|
||||
return Math.max(expectedStart, plannedEnd);
|
||||
|
||||
@@ -248,30 +248,28 @@ describe('getInsertAfterId()', () => {
|
||||
revision: 1,
|
||||
} as Rundown;
|
||||
|
||||
it('returns the provided after id', () => {
|
||||
it('returns afterId if provided', () => {
|
||||
expect(getInsertAfterId(rundown, null, 'b')).toBe('b');
|
||||
});
|
||||
|
||||
it('returns null if no anchors are provided', () => {
|
||||
it('returns null if neither afterId nor beforeId is provided', () => {
|
||||
expect(getInsertAfterId(rundown, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null if after is true', () => {
|
||||
expect(getInsertAfterId(rundown, null, true)).toBeNull();
|
||||
it('returns null if beforeId is not found', () => {
|
||||
expect(getInsertAfterId(rundown, null, undefined, 'z')).toBeNull();
|
||||
expect(getInsertAfterId(rundown, null, undefined, '1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the before id if provided', () => {
|
||||
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('2');
|
||||
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('4');
|
||||
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('group');
|
||||
it('returns the previous id of an entry in the rundown', () => {
|
||||
expect(getInsertAfterId(rundown, null, undefined, '2')).toBe('1');
|
||||
expect(getInsertAfterId(rundown, null, undefined, '4')).toBe('group');
|
||||
expect(getInsertAfterId(rundown, null, undefined, 'group')).toBe('2');
|
||||
});
|
||||
|
||||
it('returns the first top-level id if before is true', () => {
|
||||
expect(getInsertAfterId(rundown, null, undefined, true)).toBe('1');
|
||||
});
|
||||
|
||||
it('returns the first group entry if before is true inside a group', () => {
|
||||
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, true)).toBe('31');
|
||||
it('returns the previous id of an event in a group', () => {
|
||||
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '31')).toBeNull();
|
||||
expect(getInsertAfterId(rundown, rundown.entries.group as OntimeGroup, undefined, '32')).toBe('31');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,22 +331,22 @@ describe('addToRundown()', () => {
|
||||
const rundown = { id: 'test', title: '', entries: {}, order: [], flatOrder: [], revision: 0 } as Rundown;
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, null, null, null);
|
||||
addToRundown(rundown, newEntry, null, null);
|
||||
|
||||
expect(rundown.order).toEqual(['new']);
|
||||
expect(rundown.flatOrder).toEqual(['new']);
|
||||
expect(rundown.entries['new']).toBe(newEntry);
|
||||
});
|
||||
|
||||
// case 2c: insert at the end of the rundown
|
||||
it('adds at the end of order and flatOrder when afterId is null', () => {
|
||||
// case 2b: insert at the beginning of the rundown
|
||||
it('adds at the beginning of order and flatOrder when afterId is null', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, null, null, null);
|
||||
addToRundown(rundown, newEntry, null, null);
|
||||
|
||||
expect(rundown.order).toEqual(['1', '2', 'group', 'new']);
|
||||
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
|
||||
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
|
||||
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
|
||||
});
|
||||
|
||||
// case 2a: insert after a given entry at top level
|
||||
@@ -356,55 +354,19 @@ describe('addToRundown()', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, null, '1', null);
|
||||
addToRundown(rundown, newEntry, '1', null);
|
||||
|
||||
expect(rundown.order).toEqual(['1', 'new', '2', 'group']);
|
||||
expect(rundown.flatOrder).toEqual(['1', 'new', '2', 'group', '31', '32']);
|
||||
});
|
||||
|
||||
it('prepends to the rundown when before is true', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||
const afterId = getInsertAfterId(rundown, null, undefined, true);
|
||||
|
||||
addToRundown(rundown, newEntry, null, afterId, afterId);
|
||||
|
||||
expect(rundown.order).toEqual(['new', '1', '2', 'group']);
|
||||
expect(rundown.flatOrder).toEqual(['new', '1', '2', 'group', '31', '32']);
|
||||
});
|
||||
|
||||
it('inserts after a top-level group and its children in flatOrder', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, null, 'group', null);
|
||||
|
||||
expect(rundown.order).toEqual(['1', '2', 'group', 'new']);
|
||||
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
|
||||
});
|
||||
|
||||
// case 1c: insert at the end of a group
|
||||
it('inserts at the end of a group and sets parent', () => {
|
||||
// case 1b: insert at the beginning of a group
|
||||
it('inserts right after the group header in flatOrder and sets parent', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const parent = rundown.entries['group'] as OntimeGroup;
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, parent, null, null);
|
||||
|
||||
expect(parent.entries).toEqual(['31', '32', 'new']);
|
||||
expect(newEntry.parent).toBe('group');
|
||||
expect(rundown.flatOrder).toEqual(['1', '2', 'group', '31', '32', 'new']);
|
||||
// top-level order must not change when inserting into a group
|
||||
expect(rundown.order).toEqual(['1', '2', 'group']);
|
||||
});
|
||||
|
||||
it('prepends to a group when before is true', () => {
|
||||
const rundown = makeTestRundown();
|
||||
const parent = rundown.entries['group'] as OntimeGroup;
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
|
||||
const afterId = getInsertAfterId(rundown, parent, undefined, true);
|
||||
|
||||
addToRundown(rundown, newEntry, parent, afterId, afterId);
|
||||
addToRundown(rundown, newEntry, null, parent);
|
||||
|
||||
expect(parent.entries).toEqual(['new', '31', '32']);
|
||||
expect(newEntry.parent).toBe('group');
|
||||
@@ -419,7 +381,7 @@ describe('addToRundown()', () => {
|
||||
const parent = rundown.entries['group'] as OntimeGroup;
|
||||
const newEntry = { id: 'new', type: SupportedEntry.Event, parent: null } as OntimeEvent;
|
||||
|
||||
addToRundown(rundown, newEntry, parent, '31', null);
|
||||
addToRundown(rundown, newEntry, '31', parent);
|
||||
|
||||
expect(parent.entries).toEqual(['31', 'new', '32']);
|
||||
expect(newEntry.parent).toBe('group');
|
||||
|
||||
@@ -361,26 +361,31 @@ export function getNextGroupNormal(
|
||||
return { entry: null, index: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an insertion order and returns the reference to an entry ID
|
||||
* after which we will insert the new entry
|
||||
*/
|
||||
export function getInsertAfterId(
|
||||
rundown: Rundown,
|
||||
parent: OntimeGroup | null,
|
||||
afterId?: EntryId | true,
|
||||
beforeId?: EntryId | true,
|
||||
afterId?: EntryId,
|
||||
beforeId?: EntryId,
|
||||
): EntryId | null {
|
||||
if (beforeId) {
|
||||
const insertionList = parent ? parent.entries : rundown.order;
|
||||
return beforeId === true ? (insertionList[0] ?? null) : beforeId;
|
||||
}
|
||||
if (afterId) return afterId;
|
||||
if (!beforeId) return null;
|
||||
|
||||
if (afterId) return afterId === true ? null : afterId;
|
||||
const insertionList = parent ? parent.entries : rundown.order;
|
||||
if (!insertionList || insertionList.length === 0) return null;
|
||||
|
||||
return null;
|
||||
const atIndex = insertionList.findIndex((id) => id === beforeId);
|
||||
if (atIndex < 1) return null;
|
||||
return insertionList[atIndex - 1];
|
||||
}
|
||||
|
||||
type ResolveInsertParentOptions = {
|
||||
parent?: EntryId | null;
|
||||
after?: EntryId | true;
|
||||
before?: EntryId | true;
|
||||
after?: EntryId;
|
||||
before?: EntryId;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -388,21 +393,15 @@ type ResolveInsertParentOptions = {
|
||||
* Uses explicit parent first, then infers from sibling references.
|
||||
*/
|
||||
export function resolveInsertParent(rundown: Rundown, options: ResolveInsertParentOptions): EntryId | null {
|
||||
// 1. if we have a parent reference we return that
|
||||
if (options.parent !== undefined && options.parent !== null) {
|
||||
if (options.parent) {
|
||||
return options.parent;
|
||||
}
|
||||
|
||||
// 2. ... otherwise we look for a sibling and get their parent
|
||||
const referenceId = (() => {
|
||||
if (typeof options.after === 'string') return options.after;
|
||||
if (typeof options.before === 'string') return options.before;
|
||||
return undefined;
|
||||
})();
|
||||
if (referenceId === undefined) return null;
|
||||
const referenceId = options.after ?? options.before;
|
||||
if (!referenceId) return null;
|
||||
|
||||
const maybeSibling = rundown.entries[referenceId];
|
||||
if (maybeSibling !== undefined && 'parent' in maybeSibling && maybeSibling.parent !== null) {
|
||||
if (maybeSibling && 'parent' in maybeSibling && maybeSibling.parent) {
|
||||
return maybeSibling.parent;
|
||||
}
|
||||
|
||||
@@ -411,57 +410,52 @@ export function resolveInsertParent(rundown: Rundown, options: ResolveInsertPare
|
||||
|
||||
/**
|
||||
* Add entry to rundown, mutates the rundown in place.
|
||||
* if afterId and beforeId are not provided, we add at the end of the rundown
|
||||
* Handles the following cases:
|
||||
* - 1a. add entry in group, after a given entry
|
||||
* - 1b. add entry in group, at the beginning (right after the group header)
|
||||
* - 2a. add entry to the rundown, after a given entry
|
||||
* - 2b. add entry to the rundown, at the beginning
|
||||
*/
|
||||
export function addToRundown(
|
||||
rundown: Rundown,
|
||||
entry: OntimeEntry,
|
||||
parent: OntimeGroup | null,
|
||||
afterId: EntryId | null,
|
||||
beforeId: EntryId | null,
|
||||
parent: OntimeGroup | null,
|
||||
): OntimeEntry {
|
||||
// which list to use, the top level or a group order
|
||||
const insertionList = parent ? parent.entries : rundown.order;
|
||||
if (parent) {
|
||||
// 1. inserting an entry inside a group
|
||||
|
||||
// the index inside the list
|
||||
const insertionIndex = (() => {
|
||||
if (beforeId) return insertionList.indexOf(beforeId);
|
||||
if (afterId) return insertionList.indexOf(afterId) + 1;
|
||||
return insertionList.length;
|
||||
})();
|
||||
|
||||
// the index inside the flat order
|
||||
const flatIndex = (() => {
|
||||
if (beforeId) return rundown.flatOrder.indexOf(beforeId);
|
||||
// assign the parent reference on the entry
|
||||
if ('parent' in entry) {
|
||||
entry.parent = parent.id;
|
||||
}
|
||||
|
||||
if (afterId) {
|
||||
const afterEntry = rundown.entries[afterId];
|
||||
const flatReferenceId =
|
||||
!parent && isOntimeGroup(afterEntry) && afterEntry.entries?.length > 0
|
||||
? afterEntry.entries[afterEntry.entries.length - 1]
|
||||
: afterId;
|
||||
return rundown.flatOrder.indexOf(flatReferenceId) + 1;
|
||||
// 1a. insert after a given entry within the group
|
||||
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
|
||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||
parent.entries = insertAtIndex(atEventsIndex, entry.id, parent.entries);
|
||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||
} else {
|
||||
// 1b. insert at the beginning of the group (right after the group header in flatOrder)
|
||||
parent.entries = insertAtIndex(0, entry.id, parent.entries);
|
||||
const atFlatIndex = rundown.flatOrder.indexOf(parent.id) + 1;
|
||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
const previousId = insertionList[insertionIndex - 1] ?? parent.id;
|
||||
return rundown.flatOrder.indexOf(previousId) + 1;
|
||||
}
|
||||
|
||||
return rundown.flatOrder.length;
|
||||
})();
|
||||
|
||||
if (parent) {
|
||||
if (isOntimeGroup(entry)) {
|
||||
throw new Error('Cannot add a group to another group');
|
||||
}
|
||||
|
||||
entry.parent = parent.id;
|
||||
parent.entries = insertAtIndex(insertionIndex, entry.id, parent.entries);
|
||||
} else {
|
||||
rundown.order = insertAtIndex(insertionIndex, entry.id, rundown.order);
|
||||
// 2. inserting an entry at top level
|
||||
if (afterId) {
|
||||
// 2a. insert after a given entry
|
||||
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
|
||||
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
|
||||
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||
} else {
|
||||
// 2b. insert at the beginning
|
||||
rundown.order = insertAtIndex(0, entry.id, rundown.order);
|
||||
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
|
||||
}
|
||||
}
|
||||
rundown.flatOrder = insertAtIndex(flatIndex, entry.id, rundown.flatOrder);
|
||||
|
||||
// either way, we register the entry in the entries map
|
||||
rundown.entries[entry.id] = entry;
|
||||
|
||||
Reference in New Issue
Block a user