+
@@ -40,6 +44,4 @@ const RundownExport = () => {
);
-};
-
-export default memo(RundownExport);
+}
diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts
new file mode 100644
index 0000000000..d94ddff85b
--- /dev/null
+++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts
@@ -0,0 +1,399 @@
+import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
+
+import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
+
+describe('makeRundownMetadata()', () => {
+ it('processes nested rundown data', () => {
+ const selectedEventId = '12';
+ const demoEvents = {
+ '1': {
+ id: '1',
+ type: SupportedEntry.Event,
+ parent: null,
+ timeStart: 0,
+ timeEnd: 1,
+ duration: 1,
+ dayOffset: 0,
+ gap: 0,
+ skip: false,
+ linkStart: false,
+ } as OntimeEvent,
+ block: {
+ id: 'block',
+ type: SupportedEntry.Block,
+ events: ['11', 'delay', '12', '13'],
+ colour: 'red',
+ } as OntimeBlock,
+ '11': {
+ id: '11',
+ type: SupportedEntry.Event,
+ parent: 'block',
+ timeStart: 10,
+ timeEnd: 11,
+ duration: 1,
+ dayOffset: 0,
+ gap: 10,
+ skip: false,
+ linkStart: false,
+ } as OntimeEvent,
+ delay: {
+ id: 'delay',
+ type: SupportedEntry.Delay,
+ parent: 'block',
+ duration: 0,
+ } as OntimeDelay,
+ '12': {
+ id: '12',
+ type: SupportedEntry.Event,
+ parent: 'block',
+ timeStart: 11,
+ timeEnd: 12,
+ duration: 1,
+ dayOffset: 0,
+ gap: 0,
+ skip: false,
+ linkStart: true,
+ } as OntimeEvent,
+ '13': {
+ id: '13',
+ type: SupportedEntry.Event,
+ parent: 'block',
+ timeStart: 12,
+ timeEnd: 13,
+ duration: 1,
+ dayOffset: 0,
+ gap: 0,
+ skip: false,
+ linkStart: true,
+ } as OntimeEvent,
+ '2': {
+ id: '2',
+ type: SupportedEntry.Event,
+ parent: null,
+ timeStart: 20,
+ timeEnd: 21,
+ duration: 1,
+ dayOffset: 0,
+ gap: 7,
+ skip: false,
+ linkStart: false,
+ } as OntimeEvent,
+ };
+
+ const { metadata, process } = makeRundownMetadata(selectedEventId);
+
+ expect(metadata).toStrictEqual({
+ previousEvent: null,
+ latestEvent: null,
+ previousEntryId: null,
+ thisId: null,
+ eventIndex: 0,
+ isPast: true,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: null,
+ groupColour: undefined,
+ });
+
+ expect(process(demoEvents['1'])).toStrictEqual({
+ previousEvent: null,
+ latestEvent: demoEvents['1'],
+ previousEntryId: null,
+ thisId: demoEvents['1'].id,
+ eventIndex: 1, // UI indexes are 1 based
+ isPast: true,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: null,
+ groupColour: undefined,
+ });
+
+ expect(process(demoEvents['block'])).toMatchObject({
+ previousEvent: demoEvents['1'],
+ latestEvent: demoEvents['1'],
+ previousEntryId: demoEvents['1'].id,
+ thisId: demoEvents['block'].id,
+ eventIndex: 1,
+ isPast: true,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: 'block',
+ groupColour: 'red',
+ });
+
+ expect(process(demoEvents['11'])).toMatchObject({
+ previousEvent: demoEvents['1'],
+ latestEvent: demoEvents['11'],
+ previousEntryId: demoEvents['block'].id,
+ thisId: demoEvents['11'].id,
+ eventIndex: 2,
+ isPast: true,
+ isNextDay: false,
+ totalGap: 10,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: 'block',
+ groupColour: 'red',
+ });
+
+ expect(process(demoEvents['delay'])).toMatchObject({
+ previousEvent: demoEvents['11'],
+ latestEvent: demoEvents['11'],
+ previousEntryId: demoEvents['11'].id,
+ thisId: demoEvents['delay'].id,
+ eventIndex: 2,
+ isPast: true,
+ isNextDay: false,
+ totalGap: 10,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: 'block',
+ groupColour: 'red',
+ });
+
+ expect(process(demoEvents['12'])).toMatchObject({
+ previousEvent: demoEvents['11'],
+ latestEvent: demoEvents['12'],
+ previousEntryId: demoEvents['delay'].id,
+ thisId: demoEvents['12'].id,
+ eventIndex: 3,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 10,
+ isLinkedToLoaded: false,
+ isLoaded: true,
+ groupId: 'block',
+ groupColour: 'red',
+ });
+
+ expect(process(demoEvents['13'])).toMatchObject({
+ previousEvent: demoEvents['12'],
+ latestEvent: demoEvents['13'],
+ previousEntryId: demoEvents['12'].id,
+ thisId: demoEvents['13'].id,
+ eventIndex: 4,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 10,
+ isLinkedToLoaded: true,
+ isLoaded: false,
+ groupId: 'block',
+ groupColour: 'red',
+ });
+
+ expect(process(demoEvents['2'])).toMatchObject({
+ previousEvent: demoEvents['13'],
+ latestEvent: demoEvents['2'],
+ previousEntryId: demoEvents['13'].id,
+ thisId: demoEvents['2'].id,
+ eventIndex: 5,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 17,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: null,
+ groupColour: undefined,
+ });
+ });
+
+ it('populates previousEntries in blocks', () => {
+ const rundownStartsWithBlock = {
+ block: {
+ id: 'block',
+ type: SupportedEntry.Block,
+ colour: 'red',
+ events: ['1', '2'],
+ } as OntimeBlock,
+ '1': {
+ id: '1',
+ type: SupportedEntry.Event,
+ parent: 'block',
+ timeStart: 1,
+ timeEnd: 2,
+ duration: 1,
+ dayOffset: 0,
+ gap: 0,
+ skip: false,
+ linkStart: false,
+ } as OntimeEvent,
+ '2': {
+ id: '2',
+ type: SupportedEntry.Event,
+ parent: 'block',
+ timeStart: 2,
+ timeEnd: 3,
+ duration: 1,
+ dayOffset: 0,
+ gap: 0,
+ skip: false,
+ linkStart: false,
+ } as OntimeEvent,
+ };
+ const { process } = makeRundownMetadata(null);
+
+ expect(process(rundownStartsWithBlock.block)).toStrictEqual({
+ previousEvent: null,
+ latestEvent: null,
+ previousEntryId: null,
+ thisId: rundownStartsWithBlock.block.id,
+ eventIndex: 0,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: rundownStartsWithBlock.block.id,
+ groupColour: 'red',
+ });
+
+ expect(process(rundownStartsWithBlock['1'])).toStrictEqual({
+ previousEvent: null,
+ latestEvent: rundownStartsWithBlock['1'],
+ previousEntryId: rundownStartsWithBlock.block.id,
+ thisId: rundownStartsWithBlock['1'].id,
+ eventIndex: 1,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: rundownStartsWithBlock.block.id,
+ groupColour: 'red',
+ });
+ expect(process(rundownStartsWithBlock['2'])).toStrictEqual({
+ previousEvent: rundownStartsWithBlock['1'],
+ latestEvent: rundownStartsWithBlock['2'],
+ previousEntryId: rundownStartsWithBlock['1'].id,
+ thisId: rundownStartsWithBlock['2'].id,
+ eventIndex: 2,
+ isPast: false,
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: rundownStartsWithBlock.block.id,
+ groupColour: 'red',
+ });
+ });
+});
+
+describe('makeSortableList()', () => {
+ it('generates a list with block ends', () => {
+ const order = ['block-1', '2', 'block-3', 'block-4'];
+ const entries: RundownEntries = {
+ 'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
+ '11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
+ '2': { type: SupportedEntry.Event, id: '2', parent: null } as OntimeEvent,
+ 'block-3': { type: SupportedEntry.Block, id: 'block-3', events: ['31'] } as OntimeBlock,
+ '31': { type: SupportedEntry.Event, id: '31', parent: 'block-3' } as OntimeEvent,
+ 'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
+ };
+
+ const sortableList = makeSortableList(order, entries);
+ expect(sortableList).toStrictEqual([
+ 'block-1',
+ '11',
+ 'end-block-1',
+ '2',
+ 'block-3',
+ '31',
+ 'end-block-3',
+ 'block-4',
+ 'end-block-4',
+ ]);
+ });
+
+ it('closes dangling blocks', () => {
+ const order = ['block'];
+ const entries: RundownEntries = {
+ block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
+ '11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
+ '12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
+ };
+
+ const sortableList = makeSortableList(order, entries);
+ expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
+ });
+
+ it('handles a list with a with just blocks', () => {
+ const order = ['block-1', 'block-2'];
+ const entries: RundownEntries = {
+ 'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
+ 'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
+ };
+
+ const sortableList = makeSortableList(order, entries);
+ expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
+ });
+});
+
+describe('moveUp()', () => {
+ const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
+ const entries = {
+ event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
+ event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
+ block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
+ event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
+ block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
+ event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
+ };
+
+ it('moves an event up in the list', () => {
+ const result = moveUp('event2', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
+ })
+
+ it.todo('disallows nesting blocks', () => {
+ const result = moveUp('block2', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
+ })
+
+ it('moves an event into a block', () => {
+ const result = moveUp('event3', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
+ })
+
+ it('moving up from top is noop', () => {
+ const result = moveUp('event1', sortableData, entries);
+ expect(result).toMatchObject({ destinationId: null });
+ })
+});
+
+describe('moveDown()', () => {
+ const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
+ const entries = {
+ event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
+ event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
+ block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
+ event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
+ block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
+ event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
+ };
+
+ it('moves an event down in the list', () => {
+ const result = moveDown('event1', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
+ })
+
+ it.todo('disallows nesting blocks', () => {
+ const result = moveDown('block1', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
+ })
+
+ it('moves an event into a block', () => {
+ const result = moveDown('event2', sortableData, entries);
+ expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
+ })
+
+ it('moving down from bottom is noop', () => {
+ const result = moveDown('event3', sortableData, entries);
+ expect(result).toMatchObject({ destinationId: null });
+ })
+});
\ No newline at end of file
diff --git a/apps/client/src/features/rundown/_blockMixins.scss b/apps/client/src/features/rundown/_blockMixins.scss
index 0792493646..f981f3b1ab 100644
--- a/apps/client/src/features/rundown/_blockMixins.scss
+++ b/apps/client/src/features/rundown/_blockMixins.scss
@@ -1,7 +1,7 @@
@use '../../theme/ontimeColours' as *;
@use '../../theme/ontimeStyles' as *;
-$block-width: 33rem;
+$block-width: 32rem;
$block-gap: 0.25rem;
$block-element-spacing: 0.25rem;
@@ -20,28 +20,23 @@ $block-cursor-color: $orange-400;
box-sizing: content-box;
border: 1px solid $white-7;
border-radius: $block-border-radius;
- margin-block: 0.25rem;
- margin-right: 0.125rem;
position: relative;
color: $block-text-color;
min-width: $block-width;
}
-@mixin block-spacing() {
- padding-right: 0.5rem;
- gap: 2px;
-}
-
@mixin drag-style() {
font-size: 20px;
justify-self: center;
opacity: 0.3;
cursor: grab;
transition: opacity 0.3s;
+
&:hover {
opacity: 1;
}
+
&:focus {
box-shadow: none;
outline: none;
diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss
index e645de0f38..df2a63b13b 100644
--- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss
+++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss
@@ -1,28 +1,74 @@
@use '../blockMixins' as *;
.block {
- @include block-spacing;
@include block-styling;
- margin-left: 0.5rem;
+ overflow: hidden;
- background-color: $block-bg2;
-
- min-width: 34rem;
display: grid;
- grid-template-columns: 2rem 1fr auto;
+ grid-template-columns: 2rem 1fr;
+ grid-template-areas: 'binder header';
align-items: center;
- height: $secondary-block-height;
- gap: 0.5rem;
+ // TODO(style fix): groups have an extra bottom margin which interrupt colour
+ margin-block: 0.25rem;
&.hasCursor {
outline: 1px solid $block-cursor-color;
}
+
+ &.expanded {
+ border-radius: $block-border-radius $block-border-radius 0 0;
+ }
+
+ .binder {
+ grid-area: binder;
+ height: 100%;
+ background-color: var(--block-color, $gray-1050);
+ color: $section-white;
+ font-size: 1rem;
+ display: grid;
+ justify-content: center;
+ padding-top: 0.25rem;
+ }
+
+ .header {
+ grid-area: header;
+ padding-inline: 0.5rem;
+ background-color: $block-bg2;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .titleRow {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .metaRow {
+ display: flex;
+ gap: 3rem;
+ margin-bottom: 0.25rem;
+ }
+
+ .metaEntry {
+ width: 4.5em;
+
+ :first-child {
+ font-size: calc(1rem - 3px);
+ color: $label-gray;
+ }
+ }
}
.drag {
@include drag-style;
-}
-.actionMenu {
- justify-self: flex-end;
+ &.isDragging {
+ cursor: grabbing;
+ }
+
+ &.notAllowed {
+ cursor: not-allowed;
+ }
}
diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx
index a9e18753a1..7690cdef8a 100644
--- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx
+++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx
@@ -1,26 +1,57 @@
import { useRef } from 'react';
-import { IoReorderTwo } from 'react-icons/io5';
+import {
+ IoChevronDown,
+ IoChevronUp,
+ IoDuplicateOutline,
+ IoFolderOpenOutline,
+ IoReorderTwo,
+ IoTrash,
+} from 'react-icons/io5';
+import { IconButton } from '@chakra-ui/react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { OntimeBlock } from 'ontime-types';
+import { EntryId, OntimeBlock } from 'ontime-types';
-import { cx } from '../../../common/utils/styleUtils';
+import { useContextMenu } from '../../../common/hooks/useContextMenu';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
+import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
+import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
-
-import BlockDelete from './BlockDelete';
+import { canDrop } from '../rundown.utils';
import style from './BlockBlock.module.scss';
interface BlockBlockProps {
data: OntimeBlock;
hasCursor: boolean;
- onDelete: () => void;
+ collapsed: boolean;
+ onCollapse: (collapsed: boolean, groupId: EntryId) => void;
}
export default function BlockBlock(props: BlockBlockProps) {
- const { data, hasCursor, onDelete } = props;
-
+ const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef
(null);
+ const { clone, ungroup, deleteEntry } = useEntryActions();
+
+ const [onContextMenu] = useContextMenu([
+ {
+ label: 'Clone Block',
+ icon: IoDuplicateOutline,
+ onClick: () => clone(data.id),
+ },
+ {
+ label: 'Ungroup',
+ icon: IoFolderOpenOutline,
+ onClick: () => ungroup(data.id),
+ isDisabled: data.events.length === 0,
+ },
+ {
+ label: 'Delete Block',
+ icon: IoTrash,
+ onClick: () => deleteEntry([data.id]),
+ withDivider: true,
+ },
+ ]);
const {
attributes: dragAttributes,
@@ -28,25 +59,79 @@ export default function BlockBlock(props: BlockBlockProps) {
setNodeRef,
transform,
transition,
+ isDragging,
+ isOver,
+ over,
} = useSortable({
id: data.id,
+ data: {
+ type: 'block',
+ },
animateLayoutChanges: () => false,
});
+ const binderColours = data.colour && getAccessibleColour(data.colour);
+ const isValidDrop = over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
+
const dragStyle = {
+ zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
+ cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default',
};
- const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]);
-
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ onCollapse(!collapsed, data.id)}
+ color='#e2e2e2' // $gray-200
+ variant='ontime-ghosted'
+ size='sm'
+ >
+ {collapsed ? : }
+
+
+
+
+
Start
+
{formatTime(data.startTime)}
+
+
+
End
+
{formatTime(data.endTime)}
+
+
+
Duration
+
{formatDuration(data.duration)}
+
+
+
Events
+
{data.events.length}
+
+
+
);
}
diff --git a/apps/client/src/features/rundown/block-block/BlockDelete.tsx b/apps/client/src/features/rundown/block-block/BlockDelete.tsx
deleted file mode 100644
index 7fbe6bac5a..0000000000
--- a/apps/client/src/features/rundown/block-block/BlockDelete.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { IoTrash } from 'react-icons/io5';
-import { IconButton } from '@chakra-ui/react';
-
-import { AppMode, useAppMode } from '../../../common/stores/appModeStore';
-
-interface BlockDeleteProps {
- onDelete: () => void;
-}
-
-export default function BlockDelete(props: BlockDeleteProps) {
- const { onDelete } = props;
- const mode = useAppMode((state) => state.mode);
-
- const isRunMode = mode === AppMode.Run;
-
- return (
-
}
- variant='ontime-subtle'
- color='#FA5656'
- onClick={onDelete}
- isDisabled={isRunMode}
- />
- );
-}
diff --git a/apps/client/src/features/rundown/block-block/BlockEnd.module.scss b/apps/client/src/features/rundown/block-block/BlockEnd.module.scss
new file mode 100644
index 0000000000..93f06d6066
--- /dev/null
+++ b/apps/client/src/features/rundown/block-block/BlockEnd.module.scss
@@ -0,0 +1,10 @@
+@use '../blockMixins' as *;
+
+.blockEnd {
+ cursor: default;
+ height: 0.5rem;
+ background-color: var(--user-bg, $gray-1050);
+
+ border-radius: 0 0 $block-border-radius $block-border-radius;
+ margin-bottom: 0.25rem;
+}
diff --git a/apps/client/src/features/rundown/block-block/BlockEnd.tsx b/apps/client/src/features/rundown/block-block/BlockEnd.tsx
new file mode 100644
index 0000000000..a357da4213
--- /dev/null
+++ b/apps/client/src/features/rundown/block-block/BlockEnd.tsx
@@ -0,0 +1,42 @@
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+
+import style from './BlockEnd.module.scss';
+
+interface BlockEndProps {
+ id: string;
+ colour?: string;
+}
+
+export default function BlockEnd(props: BlockEndProps) {
+ const { id, colour } = props;
+ const {
+ attributes: dragAttributes,
+ listeners: dragListeners,
+ setNodeRef,
+ transform,
+ transition,
+ } = useSortable({
+ id,
+ animateLayoutChanges: () => false,
+ disabled: true, // we do not want to drag end blocks
+ });
+
+ const dragStyle = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/client/src/features/rundown/common/EditableBlockTitle.tsx b/apps/client/src/features/rundown/common/EditableBlockTitle.tsx
index cf1fe12e65..93a0e0699d 100644
--- a/apps/client/src/features/rundown/common/EditableBlockTitle.tsx
+++ b/apps/client/src/features/rundown/common/EditableBlockTitle.tsx
@@ -2,7 +2,7 @@ import { useCallback, useRef } from 'react';
import { Input } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './TitleEditor.module.scss';
@@ -16,7 +16,7 @@ interface TitleEditorProps {
export default function EditableBlockTitle(props: TitleEditorProps) {
const { title, eventId, placeholder, className } = props;
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const ref = useRef
(null);
const submitCallback = useCallback(
(text: string) => {
@@ -25,9 +25,9 @@ export default function EditableBlockTitle(props: TitleEditorProps) {
}
const cleanVal = text.trim();
- updateEvent({ id: eventId, title: cleanVal });
+ updateEntry({ id: eventId, title: cleanVal });
},
- [title, updateEvent, eventId],
+ [title, updateEntry, eventId],
);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(title, submitCallback, ref, {
diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss
index 094e801a49..bebb4d1da7 100644
--- a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss
+++ b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss
@@ -1,11 +1,11 @@
@use '../blockMixins' as *;
.delay {
- @include block-spacing;
@include block-styling;
- margin-left: 0.5rem;
+ margin-block: 0.25rem;
background-color: $block-bg2;
+ padding-right: 0.5rem;
display: grid;
grid-template-columns: 2rem 1fr auto;
diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx
index 6a8adee974..53a9ceeaf8 100644
--- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx
+++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx
@@ -6,7 +6,7 @@ import { CSS } from '@dnd-kit/utilities';
import { OntimeDelay } from 'ontime-types';
import DelayInput from '../../../common/components/input/delay-input/DelayInput';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import style from './DelayBlock.module.scss';
@@ -18,21 +18,26 @@ interface DelayBlockProps {
export default function DelayBlock(props: DelayBlockProps) {
const { data, hasCursor } = props;
- const { applyDelay, deleteEvent } = useEventAction();
+ const { applyDelay, deleteEntry } = useEntryActions();
const handleRef = useRef(null);
const {
attributes: dragAttributes,
listeners: dragListeners,
setNodeRef,
+ isDragging,
transform,
transition,
} = useSortable({
id: data.id,
+ data: {
+ type: 'delay',
+ },
animateLayoutChanges: () => false,
});
const dragStyle = {
+ zIndex: isDragging ? 2 : 'inherit',
transform: CSS.Translate.toString(transform),
transition,
};
@@ -48,7 +53,7 @@ export default function DelayBlock(props: DelayBlockProps) {
};
const cancelDelayHandler = () => {
- deleteEvent([data.id]);
+ deleteEntry([data.id]);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
diff --git a/apps/client/src/features/rundown/event-block/EventBlock.module.scss b/apps/client/src/features/rundown/event-block/EventBlock.module.scss
index 33cec42528..5da13edeb5 100644
--- a/apps/client/src/features/rundown/event-block/EventBlock.module.scss
+++ b/apps/client/src/features/rundown/event-block/EventBlock.module.scss
@@ -5,6 +5,7 @@ $skip-opacity: 0.2;
.eventBlock {
@include block-styling;
background-color: $block-bg;
+ margin-block: 0.25rem;
display: grid;
grid-template-areas:
diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx
index 71fab92229..2e35f50a47 100644
--- a/apps/client/src/features/rundown/event-block/EventBlock.tsx
+++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx
@@ -2,6 +2,7 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
IoAdd,
IoDuplicateOutline,
+ IoFolder,
IoLink,
IoPeople,
IoPeopleOutline,
@@ -12,7 +13,7 @@ import {
} from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
-import { EndAction, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
+import { EndAction, EntryId, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
@@ -26,13 +27,13 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
interface EventBlockProps {
- eventId: string;
+ eventId: EntryId;
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
- linkStart: MaybeString;
+ linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
isPublic: boolean;
@@ -45,6 +46,7 @@ interface EventBlockProps {
isPast: boolean;
isNext: boolean;
skip: boolean;
+ parent: EntryId | null;
loaded: boolean;
hasCursor: boolean;
playback?: Playback;
@@ -86,6 +88,7 @@ export default function EventBlock(props: EventBlockProps) {
isPast,
isNext,
skip = false,
+ parent,
loaded,
hasCursor,
playback,
@@ -142,6 +145,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false,
}),
},
+ { withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
@@ -151,7 +155,7 @@ export default function EventBlock(props: EventBlockProps) {
onClick: () =>
actionHandler('update', {
field: 'linkStart',
- value: linkStart ? null : 'true',
+ value: linkStart,
}),
},
{
@@ -193,6 +197,10 @@ export default function EventBlock(props: EventBlockProps) {
transition,
} = useSortable({
id: eventId,
+ data: {
+ type: 'event',
+ parent,
+ },
animateLayoutChanges: () => false,
});
diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
index c404ded561..2b180f2e9e 100644
--- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
+++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx
@@ -8,11 +8,10 @@ import {
IoPlay,
IoPlayForward,
IoPlaySkipForward,
- IoStop,
IoTime,
} from 'react-icons/io5';
import { Tooltip } from '@chakra-ui/react';
-import { EndAction, MaybeString, Playback, TimerType, TimeStrategy } from 'ontime-types';
+import { EndAction, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayMid } from '../../../ontimeConfig';
@@ -31,7 +30,7 @@ interface EventBlockInnerProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
- linkStart: MaybeString;
+ linkStart: boolean;
countToEnd: boolean;
eventIndex: number;
isPublic: boolean;
@@ -177,9 +176,6 @@ function EndActionIcon(props: { action: EndAction; className: string }) {
if (action === EndAction.PlayNext) {
return ;
}
- if (action === EndAction.Stop) {
- return ;
- }
return ;
}
diff --git a/apps/client/src/features/rundown/event-block/composite/EventBlockPlayback.tsx b/apps/client/src/features/rundown/event-block/composite/EventBlockPlayback.tsx
index a43a4cf321..8caf9f07c2 100644
--- a/apps/client/src/features/rundown/event-block/composite/EventBlockPlayback.tsx
+++ b/apps/client/src/features/rundown/event-block/composite/EventBlockPlayback.tsx
@@ -2,7 +2,7 @@ import { memo, MouseEvent } from 'react';
import { IoPause, IoPlay, IoReload, IoRemoveCircle, IoRemoveCircleOutline } from 'react-icons/io5';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
-import { useEventAction } from '../../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { setEventPlayback } from '../../../../common/hooks/useSocket';
import { tooltipDelayMid } from '../../../../ontimeConfig';
@@ -34,11 +34,11 @@ interface EventBlockPlaybackProps {
const EventBlockPlayback = (props: EventBlockPlaybackProps) => {
const { eventId, skip, isPlaying, isPaused, loaded, disablePlayback } = props;
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const toggleSkip = (event: MouseEvent) => {
event.stopPropagation();
- updateEvent({ id: eventId, skip: !skip });
+ updateEntry({ id: eventId, skip: !skip });
};
const actionHandler = (event: MouseEvent) => {
diff --git a/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx
index 8d92efc336..acceaeef9e 100644
--- a/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx
+++ b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx
@@ -15,23 +15,22 @@ interface CuesheetEventEditorProps {
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props;
const { data } = useRundown();
- const { order, rundown } = data;
const [event, setEvent] = useState(null);
useEffect(() => {
- if (order.length === 0) {
+ if (data.order.length === 0) {
setEvent(null);
return;
}
- const event = rundown[eventId];
+ const event = data.entries[eventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
- }, [data, eventId, order, rundown]);
+ }, [eventId, data.order, data.entries]);
if (!event) {
return null;
diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx
index d094e891fc..a04ab04188 100644
--- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx
+++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { CustomFieldLabel, OntimeEvent } from 'ontime-types';
import AppLink from '../../../common/components/link/app-link/AppLink';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -14,8 +14,6 @@ import EventEditorEmpty from './EventEditorEmpty';
import style from './EventEditor.module.scss';
-export type EventEditorSubmitActions = keyof OntimeEvent;
-
export type EditorUpdateFields = 'cue' | 'title' | 'note' | 'colour' | CustomFieldLabel;
interface EventEditorProps {
@@ -25,7 +23,7 @@ interface EventEditorProps {
export default function EventEditor(props: EventEditorProps) {
const { event } = props;
const { data: customFields } = useCustomFields();
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const isEditor = window.location.pathname.includes('editor');
@@ -33,12 +31,12 @@ export default function EventEditor(props: EventEditorProps) {
(field: EditorUpdateFields, value: string) => {
if (field.startsWith('custom-')) {
const fieldLabel = field.split('custom-')[1];
- updateEvent({ id: event?.id, custom: { [fieldLabel]: value } });
+ updateEntry({ id: event?.id, custom: { [fieldLabel]: value } });
} else {
- updateEvent({ id: event?.id, [field]: value });
+ updateEntry({ id: event?.id, [field]: value });
}
},
- [event?.id, updateEvent],
+ [event?.id, updateEntry],
);
if (!event) {
diff --git a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx
index f21f5a0ed0..54ccb71a35 100644
--- a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx
+++ b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx
@@ -13,29 +13,28 @@ import style from './EventEditor.module.scss';
export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown();
- const { order, rundown } = data;
const [event, setEvent] = useState(null);
useEffect(() => {
- if (order.length === 0) {
+ if (data.order.length === 0) {
setEvent(null);
return;
}
- const selectedEventId = order.find((eventId) => selectedEvents.has(eventId));
+ const selectedEventId = Array.from(selectedEvents).at(0);
if (!selectedEventId) {
setEvent(null);
return;
}
- const event = rundown[selectedEventId];
+ const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) {
setEvent(event);
} else {
setEvent(null);
}
- }, [order, rundown, selectedEvents]);
+ }, [data.order, data.entries, selectedEvents]);
if (!event) {
return ;
diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx
index f5a5f8b463..ce55afa42d 100644
--- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx
+++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { IoInformationCircle } from 'react-icons/io5';
import { Select, Switch, Tooltip } from '@chakra-ui/react';
-import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
+import { EndAction, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
-import { useEventAction } from '../../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import * as Editor from '../../../editors/editor-utils/EditorUtils';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
@@ -18,7 +18,7 @@ interface EventEditorTimesProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
- linkStart: MaybeString;
+ linkStart: boolean;
countToEnd: boolean;
delay: number;
isPublic: boolean;
@@ -46,27 +46,27 @@ function EventEditorTimes(props: EventEditorTimesProps) {
timeWarning,
timeDanger,
} = props;
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const handleSubmit = (field: HandledActions, value: string | boolean) => {
if (field === 'isPublic') {
- updateEvent({ id: eventId, isPublic: !(value as boolean) });
+ updateEntry({ id: eventId, isPublic: !(value as boolean) });
return;
}
if (field === 'countToEnd') {
- updateEvent({ id: eventId, countToEnd: !(value as boolean) });
+ updateEntry({ id: eventId, countToEnd: !(value as boolean) });
return;
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = parseUserTime(value as string);
- updateEvent({ id: eventId, [field]: newTime });
+ updateEntry({ id: eventId, [field]: newTime });
return;
}
if (field === 'timerType' || field === 'endAction') {
- updateEvent({ id: eventId, [field]: value });
+ updateEntry({ id: eventId, [field]: value });
return;
}
};
@@ -114,7 +114,6 @@ function EventEditorTimes(props: EventEditorTimesProps) {
variant='ontime'
>
None
- Stop rundown
Load next event
Play next event
diff --git a/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx
index 5aebb38415..16b106a7f3 100644
--- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx
+++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTriggers.tsx
@@ -5,7 +5,7 @@ import { TimerLifeCycle, timerLifecycleValues, Trigger } from 'ontime-types';
import { generateId } from 'ontime-utils';
import Tag from '../../../../common/components/tag/Tag';
-import { useEventAction } from '../../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { eventTriggerOptions } from './eventTrigger.constants';
@@ -37,7 +37,7 @@ interface EventTriggerFormProps {
function EventTriggerForm(props: EventTriggerFormProps) {
const { eventId, triggers } = props;
const { data: automationSettings } = useAutomationSettings();
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const [automationId, setAutomationId] = useState(undefined);
const [cycleValue, setCycleValue] = useState(TimerLifeCycle.onStart);
@@ -45,7 +45,7 @@ function EventTriggerForm(props: EventTriggerFormProps) {
const newTriggers = triggers ?? new Array();
const id = generateId();
newTriggers.push({ id, title: '', trigger: triggerLifeCycle, automationId });
- updateEvent({ id: eventId, triggers: newTriggers });
+ updateEntry({ id: eventId, triggers: newTriggers });
};
const getValidationError = (cycle: TimerLifeCycle, automationId?: string): string | undefined => {
@@ -72,7 +72,6 @@ function EventTriggerForm(props: EventTriggerFormProps) {
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
- defaultValue={TimerLifeCycle.onStart}
>
Lifecycle Trigger
{eventTriggerOptions.map((cycle) => (
@@ -123,15 +122,15 @@ interface ExistingEventTriggersProps {
function ExistingEventTriggers(props: ExistingEventTriggersProps) {
const { eventId, triggers } = props;
- const { updateEvent } = useEventAction();
+ const { updateEntry } = useEntryActions();
const { data: automationSettings } = useAutomationSettings();
const handleDelete = useCallback(
(triggerId: string) => {
const newTriggers = triggers.filter((trigger) => trigger.id !== triggerId);
- updateEvent({ id: eventId, triggers: newTriggers });
+ updateEntry({ id: eventId, triggers: newTriggers });
},
- [eventId, triggers, updateEvent],
+ [eventId, triggers, updateEntry],
);
const filteredTriggers: Record = {};
diff --git a/apps/client/src/features/rundown/placements/FinderPlacement.tsx b/apps/client/src/features/rundown/placements/FinderPlacement.tsx
new file mode 100644
index 0000000000..7fd4da09ab
--- /dev/null
+++ b/apps/client/src/features/rundown/placements/FinderPlacement.tsx
@@ -0,0 +1,22 @@
+import { memo } from 'react';
+import { useDisclosure } from '@chakra-ui/react';
+import { useHotkeys } from '@mantine/hooks';
+
+import Finder from '../../editors/finder/Finder';
+
+export default memo(FinderPlacement);
+
+function FinderPlacement() {
+ const { isOpen, onToggle, onClose } = useDisclosure();
+
+ useHotkeys([
+ ['mod + f', onToggle],
+ ['Escape', onClose],
+ ]);
+
+ if (isOpen) {
+ return ;
+ }
+
+ return null;
+}
diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss
index e413990996..d73299360f 100644
--- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss
+++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss
@@ -3,9 +3,10 @@
align-items: center;
gap: 1rem;
- margin: 0.25rem 0;
+ padding-block: 0.5rem;
font-size: calc(1rem - 3px);
- padding-inline: calc(2em + 0.5rem) 0.75rem;
+ padding-left: calc(2em + 0.5rem);
+ background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%);
}
.quickBtn {
diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx
index 1062a2d8d5..a1c88c6c96 100644
--- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx
+++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx
@@ -1,73 +1,76 @@
-import { memo, useCallback, useRef } from 'react';
+import { memo, useRef } from 'react';
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
-import { SupportedEvent } from 'ontime-types';
+import { MaybeString, SupportedEntry } from 'ontime-types';
-import { useEventAction } from '../../../common/hooks/useEventAction';
-import { useEmitLog } from '../../../common/stores/logger';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps {
- previousEventId?: string;
+ previousEventId: MaybeString;
+ parentBlock: MaybeString;
+ backgroundColor?: string;
}
export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) {
- const { previousEventId } = props;
- const { addEvent } = useEventAction();
- const { emitError } = useEmitLog();
+ const { previousEventId, parentBlock, backgroundColor } = props;
+ const { addEntry } = useEntryActions();
const doLinkPrevious = useRef(null);
const doPublic = useRef(null);
- const handleCreateEvent = useCallback(
- (eventType: SupportedEvent) => {
- switch (eventType) {
- case 'event': {
- const defaultPublic = doPublic?.current?.checked;
- const linkPrevious = doLinkPrevious?.current?.checked;
+ const addEvent = () => {
+ addEntry(
+ {
+ type: SupportedEntry.Event,
+ parent: parentBlock,
+ },
+ {
+ after: previousEventId,
+ defaultPublic: doPublic?.current?.checked,
+ lastEventId: previousEventId,
+ linkPrevious: doLinkPrevious?.current?.checked,
+ },
+ );
+ };
- const newEvent = { type: SupportedEvent.Event };
- const options = {
- after: previousEventId,
- defaultPublic,
- lastEventId: previousEventId,
- linkPrevious,
- };
- addEvent(newEvent, options);
- break;
- }
- case 'delay': {
- const options = {
- lastEventId: previousEventId,
- after: previousEventId,
- };
- addEvent({ type: SupportedEvent.Delay }, options);
- break;
- }
- case 'block': {
- const options = {
- lastEventId: previousEventId,
- after: previousEventId,
- };
- addEvent({ type: SupportedEvent.Block }, options);
- break;
- }
- default: {
- emitError(`Cannot create unknown event type: ${eventType}`);
- break;
- }
- }
- },
- [previousEventId, addEvent, emitError],
- );
+ const addDelay = () => {
+ addEntry(
+ { type: SupportedEntry.Delay, parent: parentBlock },
+ {
+ lastEventId: previousEventId,
+ after: previousEventId,
+ },
+ );
+ };
+
+ const addBlock = () => {
+ if (parentBlock !== null) {
+ return;
+ }
+ addEntry(
+ { type: SupportedEntry.Block },
+ {
+ lastEventId: previousEventId,
+ after: previousEventId,
+ },
+ );
+ };
+
+ /**
+ * If the colour is empty string ''
+ * ie: we are inside a block, but there is no defined colour
+ * we default to $gray-1050 #303030
+ */
+ const blockColour = backgroundColor === '' ? '#303030' : backgroundColor;
return (
-
+
handleCreateEvent(SupportedEvent.Event)}
+ onClick={addEvent}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
@@ -77,7 +80,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
Event
handleCreateEvent(SupportedEvent.Delay)}
+ onClick={addDelay}
size='xs'
variant='ontime-subtle-white'
className={style.quickBtn}
@@ -86,16 +89,18 @@ function QuickAddBlock(props: QuickAddBlockProps) {
>
Delay
- handleCreateEvent(SupportedEvent.Block)}
- size='xs'
- variant='ontime-subtle-white'
- className={style.quickBtn}
- leftIcon={ }
- color='#b1b1b1' // $gray-400
- >
- Block
-
+ {parentBlock === null && (
+ }
+ color='#b1b1b1' // $gray-400
+ >
+ Block
+
+ )}
);
}
diff --git a/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss b/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss
index fa551849be..a46635d284 100644
--- a/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss
+++ b/apps/client/src/features/rundown/rundown-header/RundownHeader.module.scss
@@ -1,5 +1,5 @@
.header {
- padding-inline: calc(2rem - 6px + 0.5rem) calc(1rem + 2px);
+ padding-inline: 1rem 2rem;
display: flex;
align-items: center;
diff --git a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx
index 3639f9b166..2e8cdf0943 100644
--- a/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx
+++ b/apps/client/src/features/rundown/rundown-header/RundownMenu.tsx
@@ -11,23 +11,23 @@ import {
useDisclosure,
} from '@chakra-ui/react';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useAppMode } from '../../../common/stores/appModeStore';
import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const appMode = useAppMode((state) => state.mode);
- const { deleteAllEvents } = useEventAction();
+ const { deleteAllEntries } = useEntryActions();
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef
(null);
const deleteAll = useCallback(() => {
- deleteAllEvents();
+ deleteAllEntries();
clearSelectedEvents();
onClose();
- }, [clearSelectedEvents, deleteAllEvents, onClose]);
+ }, [clearSelectedEvents, deleteAllEntries, onClose]);
return (
<>
diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts
new file mode 100644
index 0000000000..58159c6d16
--- /dev/null
+++ b/apps/client/src/features/rundown/rundown.utils.ts
@@ -0,0 +1,265 @@
+import {
+ EntryId,
+ isOntimeBlock,
+ isOntimeEvent,
+ isPlayableEvent,
+ MaybeString,
+ OntimeDelay,
+ OntimeEntry,
+ OntimeEvent,
+ PlayableEvent,
+ RundownEntries,
+ SupportedEntry,
+} from 'ontime-types';
+import { checkIsNextDay, isNewLatest } from 'ontime-utils';
+
+type RundownMetadata = {
+ previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration, used by indicators
+ latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far
+ previousEntryId: MaybeString; // previous entry is used to infer position in the rundown for new events
+ thisId: MaybeString;
+ eventIndex: number;
+ isPast: boolean;
+ isNextDay: boolean;
+ totalGap: number;
+ isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event
+ isLoaded: boolean;
+ groupId: MaybeString;
+ groupColour: string | undefined;
+};
+
+/**
+ * Creates a process function which aggregates the rundown metadata and event metadata
+ */
+export function makeRundownMetadata(selectedEventId: MaybeString) {
+ let rundownMeta: RundownMetadata = {
+ previousEvent: null,
+ latestEvent: null,
+ previousEntryId: null,
+ thisId: null,
+ eventIndex: 0,
+ isPast: Boolean(selectedEventId), // all events before the current selected are in the past
+ isNextDay: false,
+ totalGap: 0,
+ isLinkedToLoaded: false,
+ isLoaded: false,
+ groupId: null,
+ groupColour: undefined,
+ };
+
+ function process(entry: OntimeEntry): Readonly {
+ const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry);
+ rundownMeta = processedRundownMetadata;
+ return rundownMeta;
+ }
+
+ return { metadata: rundownMeta, process };
+}
+
+/**
+ * Receives a rundown entry and processes its place in the rundown
+ */
+function processEntry(
+ rundownMetadata: RundownMetadata,
+ selectedEventId: MaybeString,
+ entry: Readonly,
+): Readonly {
+ const processedData = { ...rundownMetadata };
+ // initialise data to be overridden below
+ processedData.isNextDay = false;
+ processedData.isLoaded = false;
+
+ processedData.previousEntryId = processedData.thisId; // thisId comes from the previous iteration
+ processedData.thisId = entry.id; // we reassign thisId
+ processedData.previousEvent = processedData.latestEvent;
+
+ if (entry.id === selectedEventId) {
+ processedData.isLoaded = true;
+ processedData.isPast = false;
+ }
+
+ if (isOntimeBlock(entry)) {
+ processedData.groupId = entry.id;
+ processedData.groupColour = entry.colour;
+ } else {
+ // for delays and blocks, we insert the group metadata
+ if ((entry as OntimeEvent | OntimeDelay).parent !== processedData.groupId) {
+ // if the parent is not the current group, we need to update the groupId
+ processedData.groupId = (entry as OntimeEvent | OntimeDelay).parent;
+ if ((entry as OntimeEvent | OntimeDelay).parent === null) {
+ // if the entry has no parent, it cannot have a group colour
+ processedData.groupColour = undefined;
+ }
+ }
+
+ if (isOntimeEvent(entry)) {
+ // event indexes are 1 based in UI
+ processedData.eventIndex += 1;
+
+ if (isPlayableEvent(entry)) {
+ processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
+ processedData.totalGap += entry.gap;
+
+ if (!processedData.isPast && !processedData.isLoaded) {
+ /**
+ * isLinkToLoaded is a chain value that we maintain until we
+ * a) find an unlinked event
+ * b) find a countToEnd event
+ */
+ processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
+ }
+
+ if (isNewLatest(entry, processedData.latestEvent)) {
+ // this event is the forward most event in rundown, for next iteration
+ processedData.latestEvent = entry;
+ }
+ }
+ }
+ }
+
+ return processedData;
+}
+
+/**
+ * Creates a sortable list of entries
+ * ------------------------------------
+ * Due to limitations in dnd-kit we need to flatten the list of entries
+ * This list should also be aware of any elements that are sortable (ie: block ends)
+ */
+export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
+ const flatIds: EntryId[] = [];
+
+ for (let i = 0; i < order.length; i++) {
+ const entry = entries[order[i]];
+
+ if (!entry) {
+ continue;
+ }
+
+ if (isOntimeBlock(entry)) {
+ // inside a block there are delays and events
+ // there is no need for special handling
+ flatIds.push(entry.id);
+ flatIds.push(...entry.events);
+
+ // close the block
+ flatIds.push(`end-${entry.id}`);
+ } else {
+ flatIds.push(entry.id);
+ }
+ }
+ return flatIds;
+}
+
+/**
+ * Checks whether a drop operation is valid
+ * Currently only used for validating dropping blocks
+ */
+export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | null): boolean {
+ if (targetType === 'event' || targetType === 'delay') {
+ return targetParent === null;
+ }
+ // remaining events will be block or end-block
+ // we can swap places with other blocks
+ return targetType == 'block';
+}
+
+/**
+ * Calculates destinations for an entry moving one position up in the rundown
+ * - Handles noops
+ * - Handles moving in and out of blocks
+ * TODO: handle moving blocks
+ */
+export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
+ const previousEntryId = getPreviousId(entryId, sortableData);
+
+ // the user is moving up at the top of the list
+ if (!previousEntryId) {
+ return { destinationId: null, order: 'before', isBlock: false };
+ }
+
+ if (previousEntryId.startsWith('end-')) {
+ const entry = entries[entryId];
+ if (isOntimeBlock(entry)) {
+ // if we are moving a block, we cannot insert it
+ return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
+ }
+ // insert in the block ID will add to the end of the block events
+ return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
+ }
+
+ // @ts-expect-error -- we safeguard the entry not having a parent property
+ return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
+}
+
+/**
+ * Calculates destinations for an entry moving one position down in the rundown
+ * - Handles noops
+ * - Handles moving in and out of blocks
+ * TODO: handle moving blocks
+ */
+export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
+ const nextEntryId = getNextId(entryId, sortableData);
+
+ // the user is moving down at the end of the list
+ if (!nextEntryId) {
+ return { destinationId: null, order: 'after', isBlock: false };
+ }
+
+ if (nextEntryId.startsWith('end-')) {
+ // move outside the block
+ return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
+ }
+
+ /**
+ * If the next entry is a block
+ * - 1. blocks need to skip over it
+ * - 2. if the block has children, we insert before the first child
+ * - 3. if the block is empty, we insert into the block
+ */
+ if (isOntimeBlock(entries[nextEntryId])) {
+ const entry = entries[entryId];
+
+ if (isOntimeBlock(entry)) {
+ // 1. if we are moving a block, we cannot insert it
+ return { destinationId: nextEntryId, order: 'after', isBlock: false };
+ }
+
+ const firstBlockChild = entries[nextEntryId].events.at(0);
+ if (firstBlockChild) {
+ // 2. add before the first child of the block
+ return { destinationId: firstBlockChild, order: 'before', isBlock: true };
+ } else {
+ // 3. or insert into an empty block
+ return { destinationId: nextEntryId, order: 'insert', isBlock: true };
+ }
+ }
+
+ return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
+}
+
+/**
+ * Utility function gets the ID if the next entry in the list
+ * returns null if none is found
+ */
+function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
+ const currentIndex = sortableData.indexOf(entryId);
+ if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
+ // No next ID if not found or at the end
+ return null;
+ }
+ return sortableData[currentIndex + 1];
+}
+
+/**
+ * Utility function gets the ID if the previous entry in the list
+ * returns null if none is found
+ */
+function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
+ const currentIndex = sortableData.indexOf(entryId);
+ if (currentIndex < 1) {
+ // No previous ID found or at the beginning
+ return null;
+ }
+ return sortableData[currentIndex - 1];
+}
diff --git a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
index 2cc711e5b3..993af77418 100644
--- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
+++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { IoAlertCircleOutline, IoLink, IoLockClosed, IoLockOpenOutline, IoUnlink } from 'react-icons/io5';
import { InputRightElement, Tooltip } from '@chakra-ui/react';
-import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
+import { TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx } from '../../../common/utils/styleUtils';
import { tooltipDelayFast, tooltipDelayMid } from '../../../ontimeConfig';
import * as Editor from '../../editors/editor-utils/EditorUtils';
@@ -19,14 +19,14 @@ interface EventBlockTimerProps {
timeEnd: number;
duration: number;
timeStrategy: TimeStrategy;
- linkStart: MaybeString;
+ linkStart: boolean;
delay: number;
showLabels?: boolean;
}
function TimeInputFlow(props: EventBlockTimerProps) {
const { eventId, countToEnd, timeStart, timeEnd, duration, timeStrategy, linkStart, delay, showLabels } = props;
- const { updateEvent, updateTimer } = useEventAction();
+ const { updateEntry, updateTimer } = useEntryActions();
// In sync with EventEditorTimes
const handleSubmit = (field: TimeField, value: string) => {
@@ -34,11 +34,11 @@ function TimeInputFlow(props: EventBlockTimerProps) {
};
const handleChangeStrategy = (timeStrategy: TimeStrategy) => {
- updateEvent({ id: eventId, timeStrategy });
+ updateEntry({ id: eventId, timeStrategy });
};
const handleLink = (doLink: boolean) => {
- updateEvent({ id: eventId, linkStart: doLink ? 'true' : null });
+ updateEntry({ id: eventId, linkStart: doLink });
};
const warnings = [];
@@ -55,9 +55,9 @@ function TimeInputFlow(props: EventBlockTimerProps) {
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
- const activeStart = cx([style.timeAction, linkStart ? style.active : null]);
- const activeEnd = cx([style.timeAction, isLockedEnd ? style.active : null]);
- const activeDuration = cx([style.timeAction, isLockedDuration ? style.active : null]);
+ const activeStart = cx([style.timeAction, linkStart && style.active]);
+ const activeEnd = cx([style.timeAction, isLockedEnd && style.active]);
+ const activeDuration = cx([style.timeAction, isLockedDuration && style.active]);
return (
<>
@@ -69,7 +69,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
time={timeStart}
hasDelay={hasDelay}
placeholder='Start'
- disabled={Boolean(linkStart)}
+ disabled={linkStart}
>
handleLink(!linkStart)}>
diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts
index 8602a859c1..f1591a7a4c 100644
--- a/apps/client/src/features/rundown/useEventSelection.ts
+++ b/apps/client/src/features/rundown/useEventSelection.ts
@@ -1,21 +1,21 @@
import { MouseEvent } from 'react';
-import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
+import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
import { ontimeQueryClient } from '../../common/queryClient';
import { isMacOS } from '../../common/utils/deviceUtils';
-export type SelectionMode = 'shift' | 'click' | 'ctrl';
+type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
- selectedEvents: Set;
+ selectedEvents: Set;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
- setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
+ setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
- unselect: (id: string) => void;
+ unselect: (id: EntryId) => void;
}
export const useEventSelection = create()((set, get) => ({
@@ -33,7 +33,7 @@ export const useEventSelection = create()((set, get) => ({
// on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') {
- const rundownData = ontimeQueryClient.getQueryData(RUNDOWN);
+ const rundownData = ontimeQueryClient.getQueryData(RUNDOWN);
if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor
@@ -50,7 +50,7 @@ export const useEventSelection = create()((set, get) => ({
selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex(
- (eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId),
+ (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId),
);
// if we didnt find anything after, set the anchor to the last event
@@ -62,15 +62,15 @@ export const useEventSelection = create()((set, get) => ({
// on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') {
- const rundownData = ontimeQueryClient.getQueryData(RUNDOWN);
+ const rundownData = ontimeQueryClient.getQueryData(RUNDOWN);
if (!rundownData) return;
// get list of rundown with only ontime events
- const events: OntimeEvent[] = [];
- rundownData.order.forEach((eventId) => {
- const event = rundownData.rundown[eventId];
+ const eventIds: EntryId[] = [];
+ rundownData.flatOrder.forEach((eventId) => {
+ const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) {
- events.push(event);
+ eventIds.push(event.id);
}
});
@@ -78,7 +78,7 @@ export const useEventSelection = create()((set, get) => ({
const end = anchoredIndex === null ? index : Math.max(anchoredIndex, index + 1);
// create new set with range of ids from start to end
- const selectedEventIds = events.slice(start, end).map((event) => event.id);
+ const selectedEventIds = eventIds.slice(start, end);
return set({
selectedEvents: new Set([...selectedEvents, ...selectedEventIds]),
diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx
index 90e72d922d..3d9568785d 100644
--- a/apps/client/src/features/viewers/ViewWrapper.tsx
+++ b/apps/client/src/features/viewers/ViewWrapper.tsx
@@ -8,7 +8,7 @@ import {
Runtime,
Settings,
SimpleTimerState,
- SupportedEvent,
+ SupportedEntry,
TimerType,
ViewSettings,
} from 'ontime-types';
@@ -63,7 +63,7 @@ const withData = (Component: ComponentType
) => {
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
- return rundownData.filter((e) => e.type === SupportedEvent.Event && e.title && e.isPublic);
+ return rundownData.filter((e) => e.type === SupportedEntry.Event && e.title && e.isPublic);
}
return [];
}, [rundownData]);
diff --git a/apps/client/src/features/viewers/common/animation.ts b/apps/client/src/features/viewers/common/animation.ts
deleted file mode 100644
index cbc9c6621a..0000000000
--- a/apps/client/src/features/viewers/common/animation.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-// used in both sm and public views
-export const titleVariants = {
- hidden: {
- x: -1500,
- },
- visible: {
- x: 0,
- transition: {
- duration: 1,
- },
- },
- exit: {
- x: -1500,
- },
-};
diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx
index e74bce4999..21287135c7 100644
--- a/apps/client/src/features/viewers/countdown/Countdown.tsx
+++ b/apps/client/src/features/viewers/countdown/Countdown.tsx
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
+ OntimeEntry,
OntimeEvent,
- OntimeRundownEntry,
Playback,
ProjectData,
Runtime,
Settings,
- SupportedEvent,
+ SupportedEntry,
TimerPhase,
} from 'ontime-types';
@@ -63,7 +63,7 @@ export default function Countdown(props: CountdownProps) {
}
let followThis: OntimeEvent | null = null;
- const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEvent.Event);
+ const events: OntimeEvent[] = [...backstageEvents].filter((event) => event.type === SupportedEntry.Event);
if (eventId !== null) {
followThis = events.find((event) => event.id === eventId) || null;
@@ -72,7 +72,7 @@ export default function Countdown(props: CountdownProps) {
}
if (followThis !== null) {
setFollow(followThis);
- const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
+ const idx: number = backstageEvents.findIndex((event: OntimeEntry) => event.id === followThis?.id);
const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent);
}
diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
index a23eddf4b0..0fd9676a48 100644
--- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
+++ b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
-import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
+import { OntimeEntry, OntimeEvent, SupportedEntry } from 'ontime-types';
import Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time';
@@ -10,7 +10,7 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss';
interface CountdownSelectProps {
- events: OntimeRundownEntry[];
+ events: OntimeEntry[];
}
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
@@ -19,9 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props;
const { getLocalizedString } = useTranslation();
- const filteredEvents = events.filter(
- (event: OntimeRundownEntry) => event.type === SupportedEvent.Event,
- ) as OntimeEvent[];
+ const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEntry.Event) as OntimeEvent[];
return (
diff --git a/apps/client/src/features/viewers/studio/StudioClock.tsx b/apps/client/src/features/viewers/studio/StudioClock.tsx
index 0c6a85220f..c036d6e041 100644
--- a/apps/client/src/features/viewers/studio/StudioClock.tsx
+++ b/apps/client/src/features/viewers/studio/StudioClock.tsx
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
-import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types';
+import type { MaybeString, OntimeEntry, OntimeEvent, ProjectData, Settings } from 'ontime-types';
import { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
@@ -17,7 +17,7 @@ import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss';
interface StudioClockProps {
- backstageEvents: OntimeRundown;
+ backstageEvents: OntimeEntry[];
eventNext: OntimeEvent | null;
general: ProjectData;
isMirrored: boolean;
diff --git a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx b/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx
index c97f7057b8..b1ba3a54c8 100644
--- a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx
+++ b/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx
@@ -1,4 +1,4 @@
-import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types';
+import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -8,7 +8,7 @@ import { trimRundown } from './studioClock.utils';
import './StudioClock.scss';
interface StudioClockScheduleProps {
- rundown: OntimeRundown;
+ rundown: OntimeEntry[];
selectedId: MaybeString;
nextId: MaybeString;
onAir: boolean;
diff --git a/apps/client/src/translation/TranslationProvider.tsx b/apps/client/src/translation/TranslationProvider.tsx
index 7aa37cb01b..ca0d471ad8 100644
--- a/apps/client/src/translation/TranslationProvider.tsx
+++ b/apps/client/src/translation/TranslationProvider.tsx
@@ -32,7 +32,7 @@ interface TranslationContextValue {
getLocalizedString: (key: keyof typeof langEn, lang?: string) => string;
}
-export const TranslationContext = createContext
({
+const TranslationContext = createContext({
getLocalizedString: () => '',
});
diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx
index 429ceb83a1..5d78ac23b4 100644
--- a/apps/client/src/views/common/schedule/ScheduleContext.tsx
+++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx
@@ -8,7 +8,7 @@ import {
useRef,
useState,
} from 'react';
-import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types';
+import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown';
@@ -36,7 +36,7 @@ export const ScheduleProvider = ({
isBackstage = false,
}: PropsWithChildren) => {
const { cycleInterval, stopCycle } = useScheduleOptions();
- const { data: events } = usePartialRundown((event: OntimeRundownEntry) => {
+ const { data: events } = usePartialRundown((event: OntimeEntry) => {
if (isBackstage) {
return isOntimeEvent(event);
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx
index 64bf10a676..4960f1a60d 100644
--- a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx
@@ -9,12 +9,12 @@ import {
useSensors,
} from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table';
-import { OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry } from 'ontime-types';
import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps {
- columns: ColumnDef[];
+ columns: ColumnDef[];
}
export default function CuesheetDnd(props: PropsWithChildren) {
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
index d56875be7d..a2f636f9ec 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx
@@ -1,9 +1,9 @@
import { useCallback, useRef } from 'react';
import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table';
-import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types';
+import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types';
-import { useEventAction } from '../../../common/hooks/useEventAction';
+import { useEntryActions } from '../../../common/hooks/useEntryAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent';
import { useCuesheetOptions } from '../cuesheet.options';
@@ -16,15 +16,15 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss';
interface CuesheetTableProps {
- data: OntimeRundown;
- columns: ColumnDef[];
+ data: OntimeEntry[];
+ columns: ColumnDef[];
showModal: (eventId: MaybeString) => void;
}
export default function CuesheetTable(props: CuesheetTableProps) {
const { data, columns, showModal } = props;
- const { updateEvent, updateTimer } = useEventAction();
+ const { updateEntry, updateTimer } = useEntryActions();
const { followSelected, showDelayedTimes, hideTableSeconds } = useCuesheetOptions();
const { columnVisibility, columnOrder, columnSizing, resetColumnOrder, setColumnVisibility, setColumnSizing } =
useColumnManager(columns);
@@ -64,11 +64,11 @@ export default function CuesheetTable(props: CuesheetTableProps) {
}
if (isCustom) {
- updateEvent({ id: event.id, custom: { [accessor]: payload } });
+ updateEntry({ id: event.id, custom: { [accessor]: payload } });
return;
}
- updateEvent({ id: event.id, [accessor]: payload });
+ updateEntry({ id: event.id, [accessor]: payload });
},
handleUpdateTimer: (eventId: string, field: TimeField, payload) => {
// the timer element already contains logic to avoid submitting a unchanged value
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
index e5fe04f962..e4d722b1bb 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx
@@ -1,7 +1,7 @@
import { MutableRefObject } from 'react';
import { RowModel, Table } from '@tanstack/react-table';
import Color from 'color';
-import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types';
+import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types';
import { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
@@ -13,9 +13,9 @@ import DelayRow from './DelayRow';
import EventRow from './EventRow';
interface CuesheetBodyProps {
- rowModel: RowModel;
+ rowModel: RowModel;
selectedRef: MutableRefObject;
- table: Table;
+ table: Table;
}
export default function CuesheetBody(props: CuesheetBodyProps) {
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
index 794705ca94..15511bafc9 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx
@@ -1,6 +1,6 @@
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
-import { OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options';
@@ -10,7 +10,7 @@ import { SortableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps {
- headerGroups: HeaderGroup[];
+ headerGroups: HeaderGroup[];
}
export default function CuesheetHeader(props: CuesheetHeaderProps) {
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
index a16be1e6e1..5e67bba42b 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx
@@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import Color from 'color';
-import { OntimeEvent, OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry, OntimeEvent } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
@@ -21,7 +21,7 @@ interface EventRowProps {
skip?: boolean;
colour?: string;
rowBgColour?: string;
- table: Table;
+ table: Table;
/** hack to force re-rendering of the row when the column sizes change */
columnHash: string;
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx
index 05bd089542..9ccf3f20f2 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx
@@ -2,12 +2,12 @@ import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table';
-import { OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry } from 'ontime-types';
import styles from '../CuesheetTable.module.scss';
interface SortableCellProps {
- header: Header;
+ header: Header;
style: CSSProperties;
children: ReactNode;
}
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx
index d3e75b9b6d..0ef2a8536b 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table';
-import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry, TimeStrategy } from 'ontime-types';
+import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types';
import { millisToString, removeSeconds } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -11,7 +11,7 @@ import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
-function MakeStart({ getValue, row, table }: CellContext) {
+function MakeStart({ getValue, row, table }: CellContext) {
if (!table.options.meta) {
return null;
}
@@ -22,7 +22,7 @@ function MakeStart({ getValue, row, table }: CellContext handleUpdateTimer(row.original.id, 'timeStart', newValue);
const startTime = getValue() as number;
- const isStartLocked = (row.original as OntimeEvent).linkStart === null;
+ const isStartLocked = !(row.original as OntimeEvent).linkStart;
const delayValue = (row.original as OntimeEvent)?.delay ?? 0;
const displayTime = showDelayedTimes ? startTime + delayValue : startTime;
@@ -39,7 +39,7 @@ function MakeStart({ getValue, row, table }: CellContext) {
+function MakeEnd({ getValue, row, table }: CellContext) {
if (!table.options.meta) {
return null;
}
@@ -67,7 +67,7 @@ function MakeEnd({ getValue, row, table }: CellContext) {
+function MakeDuration({ getValue, row, table }: CellContext) {
if (!table.options.meta) {
return null;
}
@@ -87,7 +87,7 @@ function MakeDuration({ getValue, row, table }: CellContext) {
+function MakeMultiLineField({ row, column, table }: CellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -101,12 +101,12 @@ function MakeMultiLineField({ row, column, table }: CellContext ;
+ return ;
}
-function LazyImage({ row, column, table }: CellContext) {
+function LazyImage({ row, column, table }: CellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -124,7 +124,7 @@ function LazyImage({ row, column, table }: CellContext ;
}
-function MakeSingleLineField({ row, column, table }: CellContext) {
+function MakeSingleLineField({ row, column, table }: CellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -138,12 +138,12 @@ function MakeSingleLineField({ row, column, table }: CellContext ;
+ return ;
}
-function MakeCustomField({ row, column, table }: CellContext) {
+function MakeCustomField({ row, column, table }: CellContext) {
const update = useCallback(
(newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -161,7 +161,7 @@ function MakeCustomField({ row, column, table }: CellContext ;
}
-export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] {
+export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key,
id: key,
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx
index f88597829b..aee6eb7933 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx
@@ -1,9 +1,9 @@
import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } from 'react-icons/io5';
import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
-import { isOntimeEvent, SupportedEvent } from 'ontime-types';
+import { isOntimeEvent, SupportedEntry } from 'ontime-types';
-import { useEventAction } from '../../../../common/hooks/useEventAction';
-import { cloneEvent } from '../../../../common/utils/eventsManager';
+import { useEntryActions } from '../../../../common/hooks/useEntryAction';
+import { cloneEvent } from '../../../../common/utils/clone';
interface CuesheetTableMenuActionsProps {
eventId: string;
@@ -13,17 +13,17 @@ interface CuesheetTableMenuActionsProps {
export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) {
const { eventId, entryIndex, showModal } = props;
- const { addEvent, getEventById, reorderEvent, deleteEvent } = useEventAction();
+ const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
const handleCloneEvent = () => {
- const currentEvent = getEventById(eventId);
+ const currentEvent = getEntryById(eventId);
if (!currentEvent || !isOntimeEvent(currentEvent)) {
return;
}
const newEvent = cloneEvent(currentEvent);
try {
- addEvent(newEvent, { after: eventId });
+ addEntry(newEvent, { after: eventId });
} catch (_error) {
// we do not handle errors here
}
@@ -35,27 +35,23 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
Edit ...
- } onClick={() => addEvent({ type: SupportedEvent.Event }, { before: eventId })}>
+ } onClick={() => addEntry({ type: SupportedEntry.Event }, { before: eventId })}>
Add event above
- } onClick={() => addEvent({ type: SupportedEvent.Event }, { after: eventId })}>
+ } onClick={() => addEntry({ type: SupportedEntry.Event }, { after: eventId })}>
Add event below
} onClick={handleCloneEvent}>
Clone event
- }
- onClick={() => reorderEvent(eventId, entryIndex, entryIndex - 1)}
- >
+ } onClick={() => move(eventId, 'up')}>
Move up
- } onClick={() => reorderEvent(eventId, entryIndex, entryIndex + 1)}>
+ } onClick={() => move(eventId, 'down')}>
Move down
- } onClick={() => deleteEvent([eventId])}>
+ } onClick={() => deleteEntry([eventId])}>
Delete
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
index 3ab8b6d181..63d4a2d966 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx
@@ -1,7 +1,7 @@
import { memo, ReactNode } from 'react';
import { Button, Checkbox } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table';
-import { OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry } from 'ontime-types';
import * as Editor from '../../../../features/editors/editor-utils/EditorUtils';
@@ -14,7 +14,7 @@ const buttonProps = {
};
interface CuesheetTableSettingsProps {
- columns: Column[];
+ columns: Column[];
handleResetResizing: () => void;
handleResetReordering: () => void;
handleClearToggles: () => void;
diff --git a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx
index 7e8e33aaad..28d41e4277 100644
--- a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx
+++ b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx
@@ -1,9 +1,9 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table';
-import { OntimeRundownEntry } from 'ontime-types';
+import { OntimeEntry } from 'ontime-types';
-export default function useColumnManager(columns: ColumnDef[]) {
+export default function useColumnManager(columns: ColumnDef[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage({
key: 'table-order',
diff --git a/apps/client/src/views/cuesheet/cuesheet.utils.ts b/apps/client/src/views/cuesheet/cuesheet.utils.ts
index 0227a7e243..169534d25c 100644
--- a/apps/client/src/views/cuesheet/cuesheet.utils.ts
+++ b/apps/client/src/views/cuesheet/cuesheet.utils.ts
@@ -3,8 +3,8 @@ import {
isOntimeDelay,
isOntimeEvent,
MaybeNumber,
+ OntimeEntry,
OntimeEntryCommonKeys,
- OntimeRundown,
ProjectData,
} from 'ontime-types';
import { millisToString } from 'ontime-utils';
@@ -32,12 +32,8 @@ export const parseField = (field: CsvHeaderKey, data: unknown): string => {
/**
* @description Creates an array of arrays usable by xlsx for export
- * @param {ProjectData} headerData
- * @param {OntimeRundown} rundown
- * @param {CustomFields} customFields
- * @return {(string[])[]}
*/
-export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => {
+export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], customFields: CustomFields): string[][] => {
// create metadata header row
const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]);
diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx
index c191bcb4e5..782ffbdcdd 100644
--- a/apps/client/src/views/timeline/Timeline.tsx
+++ b/apps/client/src/views/timeline/Timeline.tsx
@@ -1,6 +1,6 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
-import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
+import { isOntimeEvent, isPlayableEvent, OntimeEntry } from 'ontime-types';
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
@@ -11,7 +11,7 @@ import style from './Timeline.module.scss';
interface TimelineProps {
firstStart: number;
- rundown: OntimeRundown;
+ rundown: OntimeEntry[];
selectedEventId: string | null;
totalDuration: number;
}
diff --git a/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx b/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx
index 1e01f98f33..2bdeac5ad0 100644
--- a/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx
+++ b/apps/client/src/views/timeline/timeline-section/TimelineSection.tsx
@@ -12,7 +12,7 @@ interface SectionProps {
export default memo(Section);
-export function Section(props: SectionProps) {
+function Section(props: SectionProps) {
const { category, content, title, status } = props;
const sectionClasses = cx(['section', category === 'now' && 'section--now']);
diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts
index c361d5111f..847d3a0c5d 100644
--- a/apps/client/src/views/timeline/timeline.utils.ts
+++ b/apps/client/src/views/timeline/timeline.utils.ts
@@ -1,17 +1,16 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
-import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEvent, OntimeRundown, PlayableEvent } from 'ontime-types';
+import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, OntimeEvent, PlayableEvent } from 'ontime-types';
import {
dayInMs,
getEventWithId,
getFirstEvent,
getNextEvent,
- getTimeFromPrevious,
+ getTimeFrom,
isNewLatest,
MILLIS_PER_HOUR,
} from 'ontime-utils';
-import { clamp } from '../../common/utils/math';
import { formatDuration } from '../../common/utils/time';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
@@ -22,13 +21,6 @@ type CSSPosition = {
width: number;
};
-/**
- * Calculates the position (in %) of an element relative to a schedule
- */
-export function getRelativePositionX(scheduleStart: number, scheduleEnd: number, now: number): number {
- return clamp(((now - scheduleStart) / (scheduleEnd - scheduleStart)) * 100, 0, 100);
-}
-
/**
* Calculates an absolute position of an element based on a schedule
*/
@@ -95,7 +87,7 @@ interface ScopedRundownData {
totalDuration: number;
}
-export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData {
+export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams();
const data = useMemo(() => {
@@ -110,7 +102,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
- let lastEntry: PlayableEvent | undefined;
+ let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
@@ -142,7 +134,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
firstStart = currentEntry.timeStart;
}
- const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
+ const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
@@ -172,7 +164,7 @@ type UpcomingEvents = {
/**
* Returns upcoming events from current: now, next and followedBy
*/
-export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString): UpcomingEvents {
+export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents {
if (events.length === 0) {
return { now: null, next: null, followedBy: null };
}
diff --git a/apps/electron/src/main.js b/apps/electron/src/main.js
index f200ac7e33..c6dffd9074 100644
--- a/apps/electron/src/main.js
+++ b/apps/electron/src/main.js
@@ -49,9 +49,9 @@ async function startBackend() {
const ontimeServer = require(nodePath);
const { initAssets, startServer, startIntegrations } = ontimeServer;
- await initAssets();
+ await initAssets(escalateError);
- const result = await startServer(escalateError);
+ const result = await startServer();
loaded = result.message;
await startIntegrations();
diff --git a/apps/server/package.json b/apps/server/package.json
index cf27f361ed..55f7db263e 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -6,6 +6,7 @@
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+ "classic-level": "^3.0.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
@@ -26,6 +27,7 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
+ "@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts
index 5e16796dd6..fd8a91e1d1 100644
--- a/apps/server/src/adapters/WebsocketAdapter.ts
+++ b/apps/server/src/adapters/WebsocketAdapter.ts
@@ -29,7 +29,7 @@ import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null;
-export class SocketServer implements IAdapter {
+class SocketServer implements IAdapter {
private readonly MAX_PAYLOAD = 1024 * 256; // 256Kb
private wss: WebSocketServer | null;
@@ -102,7 +102,7 @@ export class SocketServer implements IAdapter {
ws.on('message', (data) => {
try {
- // @ts-expect-error -- ??
+ // @ts-expect-error -- this works fine
const message = JSON.parse(data);
const { type, payload } = message;
@@ -120,7 +120,7 @@ export class SocketServer implements IAdapter {
ws.send(
JSON.stringify({
type: 'client-name',
- payload: this.clients.get(clientId).name,
+ payload: this.getOrCreateClient(clientId),
}),
);
return;
@@ -136,7 +136,7 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-type') {
if (payload && typeof payload == 'string') {
- const previousData = this.clients.get(clientId);
+ const previousData = this.getOrCreateClient(clientId);
this.clients.set(clientId, { ...previousData, type: payload });
}
this.sendClientList();
@@ -145,7 +145,7 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-path') {
if (payload && typeof payload == 'string') {
- const previousData = this.clients.get(clientId);
+ const previousData = this.getOrCreateClient(clientId);
previousData.path = payload;
this.clients.set(clientId, previousData);
@@ -166,13 +166,13 @@ export class SocketServer implements IAdapter {
if (type === 'set-client-name') {
if (payload) {
- const previousData = this.clients.get(clientId);
+ const previousData = this.getOrCreateClient(clientId);
logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`);
this.clients.set(clientId, { ...previousData, name: payload });
ws.send(
JSON.stringify({
type: 'client-name',
- payload: this.clients.get(clientId).name,
+ payload: this.getOrCreateClient(clientId).name,
}),
);
}
@@ -215,6 +215,19 @@ export class SocketServer implements IAdapter {
};
}
+ private getOrCreateClient(clientId: string): Client {
+ if (!this.clients.has(clientId)) {
+ this.clients.set(clientId, {
+ type: 'unknown',
+ identify: false,
+ name: getRandomName(),
+ origin: '',
+ path: '',
+ });
+ }
+ return this.clients.get(clientId) as Client;
+ }
+
private sendClientList(): void {
const payload = Object.fromEntries(this.clients.entries());
this.sendAsJson({ type: 'client-list', payload });
diff --git a/apps/server/src/adapters/utils/parse.ts b/apps/server/src/adapters/utils/parse.ts
index 6bf40aae06..5e278f46ce 100644
--- a/apps/server/src/adapters/utils/parse.ts
+++ b/apps/server/src/adapters/utils/parse.ts
@@ -4,7 +4,7 @@
* @param {string} value - value to assign
* @returns {object | string | null} nested object or null if no object was created
*/
-export const integrationPayloadFromPath = (path: string[], value?: unknown): object | string | null => {
+export function integrationPayloadFromPath(path: string[], value?: unknown): object | string | null {
if (path.length === 1) {
const key = path[0];
return value === undefined ? key : { [key]: value };
@@ -16,4 +16,4 @@ export const integrationPayloadFromPath = (path: string[], value?: unknown): obj
const obj = shortenedPath.reduceRight((result, key) => ({ [key]: result }), parsedValue);
return typeof obj === 'object' ? obj : null;
-};
+}
diff --git a/apps/server/src/adapters/websocketAux.ts b/apps/server/src/adapters/websocketAux.ts
index bba45180fb..4626e904b9 100644
--- a/apps/server/src/adapters/websocketAux.ts
+++ b/apps/server/src/adapters/websocketAux.ts
@@ -1,5 +1,10 @@
import { socket } from './WebsocketAdapter.js';
+export enum RefetchTargets {
+ Rundown = 'rundown',
+ Report = 'report',
+}
+
/**
* Utility function to notify clients that the REST data is stale
* @param payload -- possible patch payload
diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts
index 344984fc28..a2ddadf416 100644
--- a/apps/server/src/api-data/automation/automation.dao.ts
+++ b/apps/server/src/api-data/automation/automation.dao.ts
@@ -169,7 +169,12 @@ async function saveChanges(patch: Partial) {
const automation = getDataProvider().getAutomation();
// remove undefined keys from object, we probably want a better solution
- Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {}));
+ Object.keys(patch).forEach((key) => {
+ const typedKey = key as keyof AutomationSettings;
+ if (patch[typedKey] === undefined) {
+ delete patch[typedKey];
+ }
+ });
await getDataProvider().setAutomation({ ...automation, ...patch });
}
diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts
index 8ed9fc02b9..42e888b9b8 100644
--- a/apps/server/src/api-data/db/db.controller.ts
+++ b/apps/server/src/api-data/db/db.controller.ts
@@ -18,9 +18,9 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response) {
try {
- const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
+ const { rundowns, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const patchDb: DatabaseModel = {
- rundown,
+ rundowns,
project,
settings,
viewSettings,
@@ -90,7 +90,7 @@ export async function quickProjectFile(req: Request, res: Response<{ filename: s
*/
export async function currentProjectDownload(_req: Request, res: Response) {
const { filename, pathToFile } = await projectService.getCurrentProject();
- res.download(pathToFile, filename, (error) => {
+ res.download(pathToFile, filename, (error: Error | null) => {
if (error) {
const message = getErrorMessage(error);
res.status(500).send({ message });
diff --git a/apps/server/src/api-data/db/db.middleware.ts b/apps/server/src/api-data/db/db.middleware.ts
index 26afef0008..4c9db3fbc7 100644
--- a/apps/server/src/api-data/db/db.middleware.ts
+++ b/apps/server/src/api-data/db/db.middleware.ts
@@ -18,7 +18,7 @@ const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilte
} else {
cb(null, false);
}
-}
+};
// Build multer uploader for a single file
export const uploadProjectFile = multer({
diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts
index c04a48d7b1..42aed0e23b 100644
--- a/apps/server/src/api-data/db/db.validation.ts
+++ b/apps/server/src/api-data/db/db.validation.ts
@@ -58,7 +58,7 @@ export const validatePatchProject = [
next();
},
- body('rundown').isArray().optional({ nullable: false }),
+ body('rundowns').isObject().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }),
diff --git a/apps/server/src/api-data/excel/excel.controller.ts b/apps/server/src/api-data/excel/excel.controller.ts
index e47bcf1b91..16d2e50722 100644
--- a/apps/server/src/api-data/excel/excel.controller.ts
+++ b/apps/server/src/api-data/excel/excel.controller.ts
@@ -5,10 +5,12 @@
import type { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
+import { CustomFields, Rundown } from 'ontime-types';
export async function postExcel(req: Request, res: Response) {
try {
- const filePath = req.file.path;
+ // file has been validated by middleware
+ const filePath = (req.file as Express.Multer.File).path;
await saveExcelFile(filePath);
res.status(200).send();
} catch (error) {
@@ -29,7 +31,10 @@ export async function getWorksheets(req: Request, res: Response) {
* parses an Excel spreadsheet
* @returns parsed result
*/
-export async function previewExcel(req: Request, res: Response) {
+export async function previewExcel(
+ req: Request,
+ res: Response<{ rundown: Rundown; customFields: CustomFields } | { message: string }>,
+) {
try {
const { options } = req.body;
const data = generateRundownPreview(options);
diff --git a/apps/server/src/api-data/excel/excel.router.ts b/apps/server/src/api-data/excel/excel.router.ts
index 7993962768..5e8541af64 100644
--- a/apps/server/src/api-data/excel/excel.router.ts
+++ b/apps/server/src/api-data/excel/excel.router.ts
@@ -12,5 +12,3 @@ export const router = express.Router();
router.post('/upload', uploadExcel, validateFileExists, postExcel);
router.get('/worksheets', getWorksheets);
router.post('/preview', validateImportMapOptions, previewExcel);
-
-// TODO: validate import map
diff --git a/apps/server/src/api-data/excel/excel.service.ts b/apps/server/src/api-data/excel/excel.service.ts
index 70d32e280c..f5d989e751 100644
--- a/apps/server/src/api-data/excel/excel.service.ts
+++ b/apps/server/src/api-data/excel/excel.service.ts
@@ -3,8 +3,8 @@
* Google Sheets
*/
-import { CustomFields, OntimeRundown } from 'ontime-types';
-import type { ImportMap } from 'ontime-utils';
+import { CustomFields, Rundown } from 'ontime-types';
+import { type ImportMap } from 'ontime-utils';
import { extname } from 'path';
import { existsSync } from 'fs';
@@ -12,10 +12,12 @@ import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js';
-import { parseRundown } from '../../utils/parserFunctions.js';
+import { parseCustomFields } from '../../utils/parserFunctions.js';
import { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
+import { parseRundown } from '../rundown/rundown.parser.js';
+
let excelData: WorkBook = xlsx.utils.book_new();
export async function saveExcelFile(filePath: string) {
@@ -34,7 +36,7 @@ export function listWorksheets(): string[] {
return excelData.SheetNames;
}
-export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } {
+export function generateRundownPreview(options: ImportMap): { rundown: Rundown; customFields: CustomFields } {
const data = excelData.Sheets[options.worksheet];
if (!data) {
@@ -43,15 +45,17 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
- const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options);
+ const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options);
+ const parsedCustomFields = parseCustomFields(dataFromExcel);
+
// we run the parsed data through an extra step to ensure the objects shape
- const { rundown, customFields } = parseRundown(dataFromExcel);
- if (rundown.length === 0) {
+ const Rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields);
+ if (Rundown.order.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
}
// clear the data
excelData = xlsx.utils.book_new();
- return { rundown, customFields };
+ return { rundown: Rundown, customFields: parsedCustomFields };
}
diff --git a/apps/server/src/api-data/report/report.router.ts b/apps/server/src/api-data/report/report.router.ts
index cb7fb622e5..7c84454732 100644
--- a/apps/server/src/api-data/report/report.router.ts
+++ b/apps/server/src/api-data/report/report.router.ts
@@ -1,10 +1,10 @@
import express from 'express';
import { getAll, deleteWithId, deleteAll } from './report.controller.js';
-import { paramsMustHaveEventId } from '../rundown/rundown.validation.js';
+import { paramsMustHaveEntryId } from '../rundown/rundown.validation.js';
export const router = express.Router();
router.get('/', getAll);
router.delete('/all', deleteAll);
-router.delete('/:eventId', paramsMustHaveEventId, deleteWithId);
+router.delete('/:eventId', paramsMustHaveEntryId, deleteWithId);
diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts
index 8cdd75f551..0b3d358a37 100644
--- a/apps/server/src/api-data/report/report.service.ts
+++ b/apps/server/src/api-data/report/report.service.ts
@@ -1,6 +1,6 @@
import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types';
import { RuntimeState } from '../../stores/runtimeState.js';
-import { sendRefetch } from '../../adapters/websocketAux.js';
+import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
import { DeepReadonly } from 'ts-essentials';
const report = new Map();
@@ -58,8 +58,7 @@ export function triggerReportEntry(
report.set(eventId, { startedAt, endedAt: state.clock });
formattedReport = null;
sendRefetch({
- target: 'REPORT',
+ target: RefetchTargets.Report,
});
- return;
}
}
diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts
new file mode 100644
index 0000000000..d6e2272c55
--- /dev/null
+++ b/apps/server/src/api-data/rundown/__tests__/rundown.parser.test.ts
@@ -0,0 +1,205 @@
+import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
+
+import { defaultRundown } from '../../../models/dataModel.js';
+import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
+
+import { parseRundowns, parseRundown } from '../rundown.parser.js';
+
+describe('parseRundowns()', () => {
+ it('returns a default project rundown if nothing is given', () => {
+ const errorEmitter = vi.fn();
+ const result = parseRundowns({}, errorEmitter);
+ expect(result.customFields).toEqual({});
+ expect(result.rundowns).toStrictEqual({ default: defaultRundown });
+ // one for not having custom fields
+ // one for not having a rundown
+ expect(errorEmitter).toHaveBeenCalledTimes(2);
+ });
+
+ it('ensures the rundown IDs are consistent', () => {
+ const errorEmitter = vi.fn();
+ const r1 = { ...defaultRundown, id: '1' };
+ const r2 = { ...defaultRundown, id: '2' };
+ const result = parseRundowns(
+ {
+ rundowns: {
+ '1': r1,
+ '3': r2,
+ },
+ },
+ errorEmitter,
+ );
+ expect(result.rundowns).toMatchObject({
+ '1': r1,
+ '2': r2,
+ });
+ // one for not having a rundown
+ expect(errorEmitter).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('parseRundown()', () => {
+ it('parses data, skipping invalid results', () => {
+ const errorEmitter = vi.fn();
+ const rundown = {
+ id: '',
+ title: '',
+ order: ['1', '2', '3', '4'],
+ flatOrder: ['1', '2', '3', '4'],
+ entries: {
+ '1': { id: '1', type: SupportedEntry.Event, title: 'test', skip: false } as OntimeEvent, // OK
+ '2': { id: '1', type: SupportedEntry.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
+ '3': {} as OntimeEvent, // no data
+ '4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {}, errorEmitter);
+ expect(parsedRundown.id).not.toBe('');
+ expect(parsedRundown.id).toBeTypeOf('string');
+ expect(parsedRundown.order.length).toEqual(1);
+ expect(parsedRundown.order).toEqual(['1']);
+ expect(parsedRundown.entries).toMatchObject({
+ '1': {
+ id: '1',
+ type: SupportedEntry.Event,
+ title: 'test',
+ skip: false,
+ },
+ });
+ expect(errorEmitter).toHaveBeenCalled();
+ });
+
+ it('stringifies necessary values', () => {
+ const rundown = {
+ id: '',
+ title: '',
+ order: ['1', '2'],
+ flatOrder: ['1', '2'],
+ entries: {
+ // @ts-expect-error -- testing external data which could be incorrect
+ '1': { id: '1', type: SupportedEntry.Event, cue: 101 } as OntimeEvent,
+ // @ts-expect-error -- testing external data which could be incorrect
+ '2': { id: '2', type: SupportedEntry.Event, cue: 101.1 } as OntimeEvent,
+ },
+ revision: 1,
+ } as Rundown;
+
+ expect(parseRundown(rundown, {})).toMatchObject({
+ entries: {
+ '1': {
+ cue: '101',
+ },
+ '2': {
+ cue: '101.1',
+ },
+ },
+ });
+ });
+
+ it('detects duplicate Ids', () => {
+ const rundown = {
+ id: '',
+ title: '',
+ order: ['1', '1'],
+ flatOrder: ['1', '1'],
+ entries: {
+ '1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
+ '2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {});
+ expect(parsedRundown.order.length).toEqual(1);
+ expect(Object.keys(parsedRundown.entries).length).toEqual(1);
+ });
+
+ it('completes partial datasets', () => {
+ const rundown = {
+ id: 'test',
+ title: '',
+ order: ['1', '2'],
+ flatOrder: ['1', '2'],
+ entries: {
+ '1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
+ '2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {});
+ expect(parsedRundown.order.length).toEqual(2);
+ expect(parsedRundown.entries).toMatchObject({
+ '1': {
+ title: '',
+ cue: '1',
+ custom: {},
+ },
+ '2': {
+ title: '',
+ cue: '2',
+ custom: {},
+ },
+ });
+ });
+
+ it('handles empty events', () => {
+ const rundown = {
+ id: 'test',
+ title: '',
+ order: ['1', '2', '3', '4'],
+ flatOrder: ['1', '2', '3', '4'],
+ entries: {
+ '1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
+ '2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
+ 'not-mentioned': {} as OntimeEvent,
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {});
+ expect(parsedRundown.order.length).toEqual(2);
+ expect(Object.keys(parsedRundown.entries).length).toEqual(2);
+ });
+
+ it('handles empty events', () => {
+ const rundown = {
+ id: 'test',
+ title: '',
+ order: ['1', '2', '3', '4'],
+ flatOrder: ['1', '2', '3', '4'],
+ entries: {
+ '1': { id: '1', type: SupportedEntry.Event } as OntimeEvent,
+ '2': { id: '2', type: SupportedEntry.Event } as OntimeEvent,
+ 'not-mentioned': {} as OntimeEvent,
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {});
+ expect(parsedRundown.order.length).toEqual(2);
+ expect(Object.keys(parsedRundown.entries).length).toEqual(2);
+ });
+
+ it('parses events nested in blocks', () => {
+ const rundown = {
+ id: 'test',
+ title: '',
+ order: ['block'],
+ flatOrder: ['block'],
+ entries: {
+ block: makeOntimeBlock({ id: 'block', events: ['1', '2'] }),
+ '1': makeOntimeEvent({ id: '1' }),
+ '2': makeOntimeEvent({ id: '2' }),
+ },
+ revision: 1,
+ } as Rundown;
+
+ const parsedRundown = parseRundown(rundown, {});
+ expect(parsedRundown.order.length).toEqual(1);
+ expect(parsedRundown.entries.block).toMatchObject({ events: ['1', '2'] });
+ expect(Object.keys(parsedRundown.entries).length).toEqual(3);
+ });
+});
diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts
new file mode 100644
index 0000000000..6ebd7e2ef4
--- /dev/null
+++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts
@@ -0,0 +1,80 @@
+import { assertType } from 'vitest';
+
+import { createEvent } from '../rundown.utils.js';
+
+describe('test event validator', () => {
+ it('validates a good object', () => {
+ const event = {
+ title: 'test',
+ };
+ const validated = createEvent(event, 1);
+
+ expect(validated).toEqual(
+ expect.objectContaining({
+ title: expect.any(String),
+ note: expect.any(String),
+ timeStart: expect.any(Number),
+ timeEnd: expect.any(Number),
+ countToEnd: expect.any(Boolean),
+ isPublic: expect.any(Boolean),
+ skip: expect.any(Boolean),
+ revision: expect.any(Number),
+ type: expect.any(String),
+ id: expect.any(String),
+ cue: '2',
+ colour: expect.any(String),
+ custom: expect.any(Object),
+ }),
+ );
+ });
+
+ it('fails an empty object', () => {
+ const event = {};
+ const validated = createEvent(event, 1);
+ expect(validated).toEqual(null);
+ });
+
+ it('makes objects strings', () => {
+ const event = {
+ title: 2,
+ note: '1899-12-30T08:00:10.000Z',
+ };
+ // @ts-expect-error -- we know this is wrong, testing imports outside domain
+ const validated = createEvent(event, 1);
+ if (validated === null) {
+ throw new Error('unexpected value');
+ }
+ expect(typeof validated.title).toEqual('string');
+ expect(typeof validated.note).toEqual('string');
+ });
+
+ it('enforces numbers on times', () => {
+ const event = {
+ timeStart: false,
+ timeEnd: '2',
+ };
+ // @ts-expect-error -- we know this is wrong, testing imports outside domain
+ const validated = createEvent(event);
+ if (validated === null) {
+ throw new Error('unexpected value');
+ }
+ assertType(validated.timeStart);
+ assertType(validated.timeEnd);
+ assertType(validated.duration);
+ expect(validated.timeStart).toEqual(0);
+ expect(validated.timeEnd).toEqual(2);
+ expect(validated.duration).toEqual(2);
+ });
+
+ it('handles bad objects', () => {
+ const event = {
+ title: {},
+ };
+ // @ts-expect-error -- we know this is wrong, testing imports outside domain
+ const validated = createEvent(event);
+ if (validated === null) {
+ throw new Error('unexpected value');
+ }
+ expect(typeof validated.title).toEqual('string');
+ });
+});
diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts
index 3575ebc510..0f173d3854 100644
--- a/apps/server/src/api-data/rundown/rundown.controller.ts
+++ b/apps/server/src/api-data/rundown/rundown.controller.ts
@@ -1,11 +1,4 @@
-import {
- ErrorResponse,
- MessageResponse,
- OntimeRundown,
- OntimeRundownEntry,
- RundownCached,
- RundownPaginated,
-} from 'ontime-types';
+import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
@@ -15,34 +8,31 @@ import {
addEvent,
applyDelay,
batchEditEvents,
- deleteAllEvents,
+ deleteAllEntries,
deleteEvent,
editEvent,
- reorderEvent,
+ ungroupEntries,
+ groupEntries,
swapEvents,
+ cloneEntry,
} from '../../services/rundown-service/RundownService.js';
-import {
- getEventWithId,
- getNormalisedRundown,
- getPaginated,
- getRundown,
-} from '../../services/rundown-service/rundownUtils.js';
-
-export async function rundownGetAll(_req: Request, res: Response) {
- const rundown = getRundown();
- res.json(rundown);
+import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js';
+
+export async function rundownGetAll(_req: Request, res: Response) {
+ const rundown = getCurrentRundown();
+ res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
}
-export async function rundownGetNormalised(_req: Request, res: Response) {
- const cachedRundown = getNormalisedRundown();
+export async function rundownGetCurrent(_req: Request, res: Response) {
+ const cachedRundown = getCurrentRundown();
res.json(cachedRundown);
}
-export async function rundownGetById(req: Request, res: Response) {
+export async function rundownGetById(req: Request, res: Response) {
const { eventId } = req.params;
try {
- const event = getEventWithId(eventId);
+ const event = getEntryWithId(eventId);
if (!event) {
res.status(404).send({ message: 'Event not found' });
@@ -55,35 +45,7 @@ export async function rundownGetById(req: Request, res: Response) {
- const { limit, offset } = req.query;
-
- if (limit == null && offset == null) {
- return res.json({
- rundown: getRundown(),
- total: getRundown().length,
- });
- }
-
- try {
- let parsedOffset = Number(offset);
- if (Number.isNaN(parsedOffset)) {
- parsedOffset = 0;
- }
- let parsedLimit = Number(limit);
- if (Number.isNaN(parsedLimit)) {
- parsedLimit = Infinity;
- }
- const paginatedRundown = getPaginated(parsedOffset, parsedLimit);
-
- res.status(200).json(paginatedRundown);
- } catch (error) {
- const message = getErrorMessage(error);
- res.status(400).json({ message });
- }
-}
-
-export async function rundownPost(req: Request, res: Response) {
+export async function rundownPost(req: Request, res: Response) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -97,7 +59,7 @@ export async function rundownPost(req: Request, res: Response) {
+export async function rundownPut(req: Request, res: Response) {
if (failEmptyObjects(req.body, res)) {
return;
}
@@ -126,40 +88,55 @@ export async function rundownBatchPut(req: Request, res: Response) {
+export async function rundownSwap(req: Request, res: Response) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
- const { eventId, from, to } = req.body;
- const event = await reorderEvent(eventId, from, to);
- res.status(200).send(event.newEvent);
+ const { from, to } = req.body;
+ await swapEvents(from, to);
+ res.status(200).send({ message: 'Swap successful' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
-export async function rundownSwap(req: Request, res: Response) {
- if (failEmptyObjects(req.body, res)) {
- return;
+export async function rundownApplyDelay(req: Request, res: Response) {
+ try {
+ await applyDelay(req.params.entryId);
+ res.status(200).send({ message: 'Delay applied' });
+ } catch (error) {
+ const message = getErrorMessage(error);
+ res.status(400).send({ message });
}
+}
+export async function rundownCloneEntry(req: Request, res: Response) {
try {
- const { from, to } = req.body;
- await swapEvents(from, to);
- res.status(200).send({ message: 'Swap successful' });
+ const newRundown = await cloneEntry(req.params.entryId);
+ res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
-export async function rundownApplyDelay(req: Request, res: Response) {
+export async function rundownUngroupEntries(req: Request, res: Response) {
try {
- await applyDelay(req.params.eventId);
- res.status(200).send({ message: 'Delay applied' });
+ const newRundown = await ungroupEntries(req.params.entryId);
+ res.status(200).send(newRundown);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ res.status(400).send({ message });
+ }
+}
+
+export async function rundownAddToBlock(req: Request, res: Response) {
+ try {
+ const newRundown = await groupEntries(req.body.ids);
+ res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
@@ -168,7 +145,7 @@ export async function rundownApplyDelay(req: Request, res: Response) {
try {
- await deleteAllEvents();
+ await deleteAllEntries();
res.status(204).send({ message: 'All events deleted' });
} catch (error) {
const message = getErrorMessage(error);
diff --git a/apps/server/src/api-data/rundown/rundown.parser.ts b/apps/server/src/api-data/rundown/rundown.parser.ts
new file mode 100644
index 0000000000..70e4fe8d40
--- /dev/null
+++ b/apps/server/src/api-data/rundown/rundown.parser.ts
@@ -0,0 +1,172 @@
+import {
+ DatabaseModel,
+ CustomFields,
+ ProjectRundowns,
+ Rundown,
+ OntimeEvent,
+ OntimeDelay,
+ OntimeBlock,
+ isOntimeEvent,
+ isOntimeDelay,
+ isOntimeBlock,
+} from 'ontime-types';
+import { isObjectEmpty, generateId } from 'ontime-utils';
+
+import { defaultRundown } from '../../models/dataModel.js';
+import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
+import { ErrorEmitter } from '../../utils/parser.js';
+import { parseCustomFields } from '../../utils/parserFunctions.js';
+
+import { createEvent } from './rundown.utils.js';
+
+/**
+ * Parse a rundowns object along with the project custom fields
+ * Returns a default rundown if none exists
+ */
+export function parseRundowns(
+ data: Partial,
+ emitError?: ErrorEmitter,
+): { customFields: CustomFields; rundowns: ProjectRundowns } {
+ // check custom fields first
+ const parsedCustomFields = parseCustomFields(data, emitError);
+
+ // ensure there is always a rundown to import
+ // this is important since the rest of the app assumes this exist
+ if (!data.rundowns || isObjectEmpty(data.rundowns)) {
+ emitError?.('No data found to import');
+ return {
+ customFields: parsedCustomFields,
+ rundowns: {
+ default: {
+ ...defaultRundown,
+ },
+ },
+ };
+ }
+
+ const parsedRundowns: ProjectRundowns = {};
+ const iterableRundownsIds = Object.keys(data.rundowns);
+
+ // parse all the rundowns individually
+ for (const id of iterableRundownsIds) {
+ console.log('Found rundown, importing...');
+ const rundown = data.rundowns[id];
+ const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError);
+ parsedRundowns[parsedRundown.id] = parsedRundown;
+ }
+
+ return { customFields: parsedCustomFields, rundowns: parsedRundowns };
+}
+
+/**
+ * Parses and validates a single project rundown along with given project custom fields
+ */
+export function parseRundown(
+ rundown: Rundown,
+ parsedCustomFields: Readonly,
+ emitError?: ErrorEmitter,
+): Rundown {
+ const parsedRundown: Rundown = {
+ id: rundown.id || generateId(),
+ title: rundown.title ?? '',
+ entries: {},
+ order: [],
+ flatOrder: [],
+ revision: rundown.revision ?? 1,
+ };
+
+ let eventIndex = 0;
+
+ for (let i = 0; i < rundown.order.length; i++) {
+ const entryId = rundown.order[i];
+ const event = rundown.entries[entryId];
+
+ if (event === undefined) {
+ emitError?.('Could not find referenced event, skipping');
+ continue;
+ }
+
+ if (parsedRundown.order.includes(event.id)) {
+ emitError?.('ID collision on event import, skipping');
+ continue;
+ }
+
+ const id = entryId;
+ let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
+ const nestedEntryIds: string[] = [];
+
+ if (isOntimeEvent(event)) {
+ newEvent = createEvent(event, eventIndex);
+ // skip if event is invalid
+ if (newEvent == null) {
+ emitError?.('Skipping event without payload');
+ continue;
+ }
+
+ // for every field in custom, check that a key exists in customfields
+ for (const field in newEvent.custom) {
+ if (!Object.hasOwn(parsedCustomFields, field)) {
+ emitError?.(`Custom field ${field} not found`);
+ delete newEvent.custom[field];
+ }
+ }
+
+ eventIndex += 1;
+ } else if (isOntimeDelay(event)) {
+ newEvent = { ...delayDef, duration: event.duration, id };
+ } else if (isOntimeBlock(event)) {
+ for (let i = 0; i < event.events.length; i++) {
+ const nestedEventId = event.events[i];
+ const nestedEvent = rundown.entries[nestedEventId];
+
+ if (isOntimeEvent(nestedEvent)) {
+ const newNestedEvent = createEvent(nestedEvent, eventIndex);
+ // skip if event is invalid
+ if (newNestedEvent == null) {
+ emitError?.('Skipping event without payload');
+ continue;
+ }
+
+ // for every field in custom, check that a key exists in customfields
+ for (const field in newNestedEvent.custom) {
+ if (!Object.hasOwn(parsedCustomFields, field)) {
+ emitError?.(`Custom field ${field} not found`);
+ delete newNestedEvent.custom[field];
+ }
+ }
+
+ eventIndex += 1;
+
+ if (newNestedEvent) {
+ nestedEntryIds.push(nestedEventId);
+ parsedRundown.entries[nestedEventId] = newNestedEvent;
+ }
+ }
+ }
+
+ newEvent = {
+ ...blockDef,
+ title: event.title,
+ note: event.note,
+ events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
+ skip: event.skip,
+ colour: event.colour,
+ custom: { ...event.custom },
+ id,
+ };
+ } else {
+ emitError?.('Unknown event type, skipping');
+ continue;
+ }
+
+ if (newEvent) {
+ parsedRundown.entries[id] = newEvent;
+ parsedRundown.order.push(id);
+ parsedRundown.flatOrder.push(id);
+ parsedRundown.flatOrder.push(...nestedEntryIds);
+ }
+ }
+
+ console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
+ return parsedRundown;
+}
diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts
index ebac101cbe..e1c4451c5c 100644
--- a/apps/server/src/api-data/rundown/rundown.router.ts
+++ b/apps/server/src/api-data/rundown/rundown.router.ts
@@ -1,24 +1,30 @@
+import { ErrorResponse, Rundown } from 'ontime-types';
+import { getErrorMessage } from 'ontime-utils';
+
+import type { Request, Response } from 'express';
import express from 'express';
+import { reorderEntry } from '../../services/rundown-service/RundownService.js';
+
import {
deletesEventById,
+ rundownAddToBlock,
rundownApplyDelay,
rundownBatchPut,
+ rundownCloneEntry,
rundownDelete,
+ rundownUngroupEntries,
rundownGetAll,
rundownGetById,
- rundownGetNormalised,
- rundownGetPaginated,
+ rundownGetCurrent,
rundownPost,
rundownPut,
- rundownReorder,
rundownSwap,
} from './rundown.controller.js';
import {
- paramsMustHaveEventId,
+ paramsMustHaveEntryId,
rundownArrayOfIds,
rundownBatchPutValidator,
- rundownGetPaginatedQueryParams,
rundownPostValidator,
rundownPutValidator,
rundownReorderValidator,
@@ -27,19 +33,30 @@ import {
export const router = express.Router();
-router.get('/', rundownGetAll); // not used in Ontime frontend
-router.get('/paginated', rundownGetPaginatedQueryParams, rundownGetPaginated); // not used in Ontime frontend
-router.get('/normalised', rundownGetNormalised);
-router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
+router.get('/', rundownGetAll);
+router.get('/current', rundownGetCurrent);
+router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
-router.patch('/reorder/', rundownReorderValidator, rundownReorder);
+router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response) => {
+ try {
+ const { entryId, destinationId, order } = req.body;
+ const newRundown = await reorderEntry(entryId, destinationId, order);
+ res.status(200).send(newRundown);
+ } catch (error) {
+ const message = getErrorMessage(error);
+ res.status(400).send({ message });
+ }
+});
router.patch('/swap', rundownSwapValidator, rundownSwap);
-router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
+router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay);
+router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry);
+router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries);
+router.post('/group', rundownArrayOfIds, rundownAddToBlock);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts
new file mode 100644
index 0000000000..064fcd0529
--- /dev/null
+++ b/apps/server/src/api-data/rundown/rundown.utils.ts
@@ -0,0 +1,112 @@
+import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
+import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
+
+import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js';
+import { makeString } from '../../utils/parserUtils.js';
+
+export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial): OntimeEvent {
+ if (Object.keys(patchEvent).length === 0) {
+ return originalEvent;
+ }
+
+ const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
+ patchEvent?.timeStart ?? originalEvent.timeStart,
+ patchEvent?.timeEnd ?? originalEvent.timeEnd,
+ patchEvent?.duration ?? originalEvent.duration,
+ patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
+ );
+
+ return {
+ id: originalEvent.id,
+ type: SupportedEntry.Event,
+ title: makeString(patchEvent.title, originalEvent.title),
+ timeStart,
+ timeEnd,
+ duration,
+ timeStrategy,
+ linkStart: typeof patchEvent.linkStart === 'boolean' ? patchEvent.linkStart : originalEvent.linkStart,
+ endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
+ timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
+ countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
+ isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
+ skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
+ note: makeString(patchEvent.note, originalEvent.note),
+ colour: makeString(patchEvent.colour, originalEvent.colour),
+ delay: originalEvent.delay, // is regenerated if timer related data is changed
+ dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
+ gap: originalEvent.gap, // is regenerated if timer related data is changed
+ // short circuit empty string
+ cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
+ parent: originalEvent.parent,
+ revision: originalEvent.revision,
+ timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
+ timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
+ custom: { ...originalEvent.custom, ...patchEvent.custom },
+ triggers: patchEvent.triggers ?? originalEvent.triggers,
+ };
+}
+
+/**
+ * @description Enforces formatting for events
+ * @param {object} eventArgs - attributes of event
+ * @param {number} eventIndex - can be a string when we pass the a suggested cue name
+ * @returns {object|null} - formatted object or null in case is invalid
+ */
+export const createEvent = (eventArgs: Partial, eventIndex: number | string): OntimeEvent | null => {
+ if (Object.keys(eventArgs).length === 0) {
+ return null;
+ }
+
+ const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
+
+ const baseEvent = {
+ id: eventArgs?.id ?? generateId(),
+ cue,
+ ...eventDef,
+ };
+ const event = createPatch(baseEvent, eventArgs);
+ return event;
+};
+
+/**
+ * Creates a new block from an optional patch
+ */
+export function createBlock(patch?: Partial): OntimeBlock {
+ if (!patch) {
+ return { ...blockDef, id: generateId() };
+ }
+
+ return {
+ id: patch.id ?? generateId(),
+ type: SupportedEntry.Block,
+ title: patch.title ?? '',
+ note: patch.note ?? '',
+ events: patch.events ?? [],
+ skip: patch.skip ?? false,
+ colour: makeString(patch.colour, ''),
+ custom: patch.custom ?? {},
+ revision: 0,
+ startTime: null,
+ endTime: null,
+ duration: 0,
+ isFirstLinked: false,
+ };
+}
+
+/**
+ * Function infers strategy for a patch with only partial timer data
+ * @param end
+ * @param duration
+ * @param fallback
+ * @returns
+ */
+function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
+ if (end && !duration) {
+ return TimeStrategy.LockEnd;
+ }
+
+ if (!end && duration) {
+ return TimeStrategy.LockDuration;
+ }
+ return fallback;
+}
diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts
index d815bb5766..6a396f21da 100644
--- a/apps/server/src/api-data/rundown/rundown.validation.ts
+++ b/apps/server/src/api-data/rundown/rundown.validation.ts
@@ -1,4 +1,4 @@
-import { body, param, query, validationResult } from 'express-validator';
+import { body, param, validationResult } from 'express-validator';
import type { Request, Response, NextFunction } from 'express';
export const rundownPostValidator = [
@@ -35,9 +35,9 @@ export const rundownBatchPutValidator = [
];
export const rundownReorderValidator = [
- body('eventId').isString().exists(),
- body('from').isNumeric().exists(),
- body('to').isNumeric().exists(),
+ body('entryId').isString().exists(),
+ body('destinationId').isString().exists(),
+ body('order').isIn(['before', 'after', 'insert']).exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -57,8 +57,8 @@ export const rundownSwapValidator = [
},
];
-export const paramsMustHaveEventId = [
- param('eventId').exists(),
+export const paramsMustHaveEntryId = [
+ param('entryId').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -68,7 +68,7 @@ export const paramsMustHaveEventId = [
];
export const rundownArrayOfIds = [
- body('ids').isArray().exists(),
+ body('ids').isArray().notEmpty(),
body('ids.*').isString(),
(req: Request, res: Response, next: NextFunction) => {
@@ -77,14 +77,3 @@ export const rundownArrayOfIds = [
next();
},
];
-
-export const rundownGetPaginatedQueryParams = [
- query('offset').isNumeric().optional(),
- query('limit').isNumeric().optional(),
-
- (req: Request, res: Response, next: NextFunction) => {
- const errors = validationResult(req);
- if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
- next();
- },
-];
diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts
index 168121fc96..dde25cc215 100644
--- a/apps/server/src/api-data/session/session.service.ts
+++ b/apps/server/src/api-data/session/session.service.ts
@@ -4,7 +4,7 @@ import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { publicDir } from '../../setup/index.js';
import { socket } from '../../adapters/WebsocketAdapter.js';
import { getLastRequest } from '../../api-integration/integration.controller.js';
-import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
+import { getCurrentProject } from '../../services/project-service/ProjectService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
@@ -17,7 +17,7 @@ const startedAt = new Date();
export async function getSessionStats(): Promise {
const { connectedClients, lastConnection } = socket.getStats();
const lastRequest = getLastRequest();
- const projectName = await getLastLoadedProject();
+ const { filename } = await getCurrentProject();
const { playback } = runtimeService.getRuntimeState();
return {
@@ -25,7 +25,7 @@ export async function getSessionStats(): Promise {
connectedClients,
lastConnection: lastConnection !== null ? lastConnection.toISOString() : null,
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
- projectName,
+ projectName: filename,
playback,
timezone: getTimezoneLabel(startedAt),
};
diff --git a/apps/server/src/api-data/sheets/sheets.controller.ts b/apps/server/src/api-data/sheets/sheets.controller.ts
index 026e5384a5..59a86636ed 100644
--- a/apps/server/src/api-data/sheets/sheets.controller.ts
+++ b/apps/server/src/api-data/sheets/sheets.controller.ts
@@ -6,7 +6,7 @@
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
-import type { AuthenticationStatus, CustomFields, ErrorResponse, OntimeRundown } from 'ontime-types';
+import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types';
import { deleteFile } from '../../utils/parserUtils.js';
import {
@@ -25,10 +25,11 @@ export async function requestConnection(
res: Response<{ verification_url: string; user_code: string } | ErrorResponse>,
) {
const { sheetId } = req.params;
- const file = req.file.path;
+ // the check for the file is done in the validation middleware
+ const filePath = (req.file as Express.Multer.File).path;
try {
- const client = readFileSync(file, 'utf-8');
+ const client = readFileSync(filePath, 'utf-8');
const clientSecret = handleClientSecret(client);
const { verification_url, user_code } = await handleInitialConnection(clientSecret, sheetId);
@@ -40,7 +41,7 @@ export async function requestConnection(
// delete uploaded file after parsing
try {
- deleteFile(file);
+ await deleteFile(filePath);
} catch (_error) {
/** we dont handle failure here */
}
@@ -87,7 +88,7 @@ export async function readFromSheet(
req: Request,
res: Response<
| {
- rundown: OntimeRundown;
+ rundown: Rundown;
customFields: CustomFields;
}
| ErrorResponse
diff --git a/apps/server/src/api-data/sheets/sheets.validation.ts b/apps/server/src/api-data/sheets/sheets.validation.ts
index 3f15b02240..d1016efcad 100644
--- a/apps/server/src/api-data/sheets/sheets.validation.ts
+++ b/apps/server/src/api-data/sheets/sheets.validation.ts
@@ -16,6 +16,10 @@ export const validateRequestConnection = [
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
+ // check that the file exists
+ if (!req.file) {
+ return res.status(422).json({ errors: 'File not found' });
+ }
next();
},
];
diff --git a/apps/server/src/api-data/url-presets/urlPresets.controller.ts b/apps/server/src/api-data/url-presets/urlPresets.controller.ts
index c07c24b3c3..fb763c29a8 100644
--- a/apps/server/src/api-data/url-presets/urlPresets.controller.ts
+++ b/apps/server/src/api-data/url-presets/urlPresets.controller.ts
@@ -16,7 +16,7 @@ export async function postUrlPresets(req: Request, res: Response ({
+ const newPresets: URLPreset[] = req.body.map((preset: URLPreset) => ({
enabled: preset.enabled,
alias: preset.alias,
pathAndParams: preset.pathAndParams,
diff --git a/apps/server/src/api-integration/__tests__/integration.legacy.test.ts b/apps/server/src/api-integration/__tests__/integration.legacy.test.ts
deleted file mode 100644
index 6dd81fa28a..0000000000
--- a/apps/server/src/api-integration/__tests__/integration.legacy.test.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { handleLegacyMessageConversion } from '../integration.legacy.js';
-
-describe('handleLegacyConversion', () => {
- it('should return the payload as is if it is not a legacy message', () => {
- expect(handleLegacyMessageConversion({})).toEqual({});
- const newPayload = {
- timer: {
- text: 'text',
- visible: true,
- blink: true,
- blackout: true,
- },
- external: 'text',
- };
- expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
- });
-
- it('should convert a legacy payload with external message', () => {
- expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
- external: 'text',
- timer: {
- secondarySource: 'external',
- },
- });
-
- expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
- timer: {
- secondarySource: 'external',
- },
- });
-
- expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
- external: 'text',
- });
- });
-});
diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts
index 73a6e3ac19..1f48995ab9 100644
--- a/apps/server/src/api-integration/integration.controller.ts
+++ b/apps/server/src/api-integration/integration.controller.ts
@@ -1,5 +1,5 @@
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
-import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
+import { MILLIS_PER_HOUR } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials';
@@ -14,9 +14,7 @@ import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
-import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
-
-import { handleLegacyMessageConversion } from './integration.legacy.js';
+import { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js';
import { coerceEnum } from '../utils/coerceType.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
@@ -90,12 +88,9 @@ const actionHandlers: Record = {
message: (payload) => {
assert.isObject(payload);
- // TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
- const migratedPayload = handleLegacyMessageConversion(payload);
-
const patch: DeepPartial = {
- timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
- external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
+ timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
+ external: 'external' in payload ? validateMessage(payload.external) : undefined,
};
const newMessage = messageService.patch(patch);
@@ -192,27 +187,24 @@ const actionHandlers: Record = {
throw new Error('No matching method provided');
},
addtime: (payload) => {
- let time = 0;
- if (payload && typeof payload === 'object') {
- if ('add' in payload) {
- time = numberOrError(payload.add);
- } else if ('remove' in payload) {
- time = numberOrError(payload.remove) * -1;
+ const time = (() => {
+ if (payload && typeof payload === 'object') {
+ if ('add' in payload) return numberOrError(payload.add);
+ if ('remove' in payload) return numberOrError(payload.remove) * -1;
}
- } else {
- time = numberOrError(payload);
- }
+ return numberOrError(payload);
+ })();
+
assert.isNumber(time);
if (time === 0) {
return { payload: 'success' };
}
- const timeToAdd = time * MILLIS_PER_SECOND; // frontend is seconds based
- if (Math.abs(timeToAdd) > MILLIS_PER_HOUR) {
+ if (Math.abs(time) > MILLIS_PER_HOUR) {
throw new Error(`Payload too large: ${time}`);
}
- runtimeService.addTime(timeToAdd);
+ runtimeService.addTime(time);
return { payload: 'success' };
},
/* Extra timers */
@@ -238,13 +230,11 @@ const actionHandlers: Record = {
} else if (command && typeof command === 'object') {
const reply = { payload: {} };
if ('duration' in command) {
- // convert duration in seconds to ms
- const timeInMs = numberOrError(command.duration) * 1000;
+ const timeInMs = numberOrError(command.duration);
reply.payload = auxTimerService.setTime(timeInMs);
}
if ('addtime' in command) {
- // convert addTime in seconds to ms
- const timeInMs = numberOrError(command.addtime) * 1000;
+ const timeInMs = numberOrError(command.addtime);
reply.payload = auxTimerService.addTime(timeInMs);
}
if ('direction' in command) {
diff --git a/apps/server/src/api-integration/integration.legacy.ts b/apps/server/src/api-integration/integration.legacy.ts
deleted file mode 100644
index d24a580802..0000000000
--- a/apps/server/src/api-integration/integration.legacy.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { MessageState } from 'ontime-types';
-import { DeepPartial } from 'ts-essentials';
-
-export type LegacyMessageState = DeepPartial<{
- timer: {
- text: string;
- visible: boolean;
- blink: boolean;
- blackout: boolean;
- };
- external: {
- text: string;
- visible: boolean;
- };
-}>;
-
-function isLegacyMessageState(value: object): value is LegacyMessageState {
- // @ts-expect-error -- good enough here
- return value?.external?.text !== undefined || value?.external?.visible !== undefined;
-}
-
-/**
- * This function is used to maintain support for legacy data in the /message endpoint
- * The previous message endpoint expected a patch of the message state
- * @example {
- * timer: { blink: boolean, blackout: boolean, text: string, visible: boolean },
- * external: { visible: boolean, text: string }
- * }
- *
- * This change is introduced in version 3.6.0
- */
-export function handleLegacyMessageConversion(payload: object): object | Partial {
- // if it is not a legacy message, we pass it as is
- if (!isLegacyMessageState(payload)) {
- return payload;
- }
-
- /**
- * The current migration only needs to handle the cases
- * for the deprecated external message controls
- */
-
- // Migrate external message
- // 2.1 the user gives us the text and a visible flag
- if (payload?.external?.text !== undefined && payload.external.visible !== undefined) {
- return {
- timer: { secondarySource: payload.external.visible ? 'external' : null },
- external: payload.external.text,
- } as Partial;
- }
- // 2.2 the user gives us the text
- else if (payload?.external?.text !== undefined) {
- return {
- external: payload.external.text,
- } as Partial;
- }
- // 2.3 the user gives us the visible flag
- else if (payload?.external?.visible !== undefined) {
- return {
- timer: { secondarySource: payload.external.visible ? 'external' : null },
- } as Partial;
- }
-
- // there should be no case for us to reach this since
- // the type guard would have ensured one of the above states
- return payload;
-}
diff --git a/apps/server/src/api-integration/integration.router.ts b/apps/server/src/api-integration/integration.router.ts
index c652d49a20..a403794bbd 100644
--- a/apps/server/src/api-integration/integration.router.ts
+++ b/apps/server/src/api-integration/integration.router.ts
@@ -36,8 +36,9 @@ integrationRouter.get('/*', (req: Request, res: Response) => {
try {
const actionArray = action.split('/');
const query = isEmptyObject(req.query) ? undefined : (req.query as object);
- let payload = {};
+ let payload: unknown = {};
if (actionArray.length > 1) {
+ // @ts-expect-error -- we decide to give up on typing here
action = actionArray.shift();
payload = integrationPayloadFromPath(actionArray, query);
} else {
diff --git a/apps/server/src/api-integration/integration.utils.ts b/apps/server/src/api-integration/integration.utils.ts
index eaa455eb8d..264411ea78 100644
--- a/apps/server/src/api-integration/integration.utils.ts
+++ b/apps/server/src/api-integration/integration.utils.ts
@@ -2,7 +2,7 @@ import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'o
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { editEvent } from '../services/rundown-service/RundownService.js';
-import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
+import { getEntryWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
@@ -64,7 +64,7 @@ export function parseProperty(property: string, value: unknown) {
* @param {Partial} patchEvent
*/
export function updateEvent(patchEvent: Partial & { id: string }) {
- const event = getEventWithId(patchEvent?.id ?? '');
+ const event = getEntryWithId(patchEvent?.id ?? '');
if (!event) {
throw new Error(`Event with ID ${patchEvent?.id} not found`);
}
diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts
index 5dda9aa1e1..48f43f385a 100644
--- a/apps/server/src/app.ts
+++ b/apps/server/src/app.ts
@@ -43,7 +43,7 @@ import { oscServer } from './adapters/OscAdapter.js';
// Utilities
import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
-import { timerConfig } from './config/config.js';
+import { timerConfig } from './setup/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
console.log('\n');
@@ -140,8 +140,11 @@ const checkStart = (currentState: OntimeStartOrder) => {
}
};
-export const initAssets = async () => {
+export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable: boolean) => void) => {
checkStart(OntimeStartOrder.InitAssets);
+ // initialise logging service, escalateErrorFn only exists in electron
+ logger.init(escalateErrorFn);
+
await clearUploadfolder();
populateStyles();
await populateDemo();
@@ -152,12 +155,8 @@ export const initAssets = async () => {
/**
* Starts servers
*/
-export const startServer = async (
- escalateErrorFn?: (error: string, unrecoverable: boolean) => void,
-): Promise<{ message: string; serverPort: number }> => {
+export const startServer = async (): Promise<{ message: string; serverPort: number }> => {
checkStart(OntimeStartOrder.InitServer);
- // initialise logging service, escalateErrorFn only exists in electron
- logger.init(escalateErrorFn);
const settings = getDataProvider().getSettings();
const { serverPort: desiredPort } = settings;
@@ -208,7 +207,6 @@ export const startServer = async (
// load restore point if it exists
const maybeRestorePoint = await restoreService.load();
- // TODO: pass event store to rundownservice
runtimeService.init(maybeRestorePoint);
const nif = getNetworkInterfaces();
@@ -245,47 +243,69 @@ export const startIntegrations = async () => {
* @param {number} exitCode
* @return {Promise}
*/
-export const shutdown = async (exitCode = 0) => {
- consoleHighlight(`Ontime shutting down with code ${exitCode}`);
+const shutdown = (exitCode = 0) => {
+ consoleHighlight(`Ontime shutting down with code: ${exitCode}`);
+
+ // sync shutdowns
+ oscServer.shutdown();
+ socket.shutdown();
+ runtimeService.shutdown();
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 99 means there was a shutdown request from the UI
- if (exitCode === 0 || exitCode === 99) {
- await restoreService.clear();
- }
- expressServer?.close();
- runtimeService.shutdown();
+ const pendingRestoreService = new Promise((resolve, _reject) => {
+ if (exitCode === 0 || exitCode === 99) {
+ restoreService.clear().then(resolve);
+ }
+ resolve;
+ });
+
+ const pendingExpressServer = new Promise((resolve, _reject) => {
+ expressServer?.close(resolve);
+ });
+
+ const pendingDataProvider = new Promise((resolve, _reject) => {
+ getDataProvider().shutdown().then(resolve);
+ });
+
+ Promise.all([pendingRestoreService, pendingExpressServer, pendingDataProvider]);
+
logger.shutdown();
- oscServer.shutdown();
- socket.shutdown();
- process.exit(exitCode);
+
+ expressServer?.close(() => {
+ getDataProvider()
+ .shutdown()
+ .then(() => {
+ process.exit(exitCode);
+ });
+ });
};
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
-process.on('unhandledRejection', async (error) => {
+process.on('unhandledRejection', (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught rejection | ${error}`);
- await shutdown(1);
+ shutdown(1);
});
-process.on('uncaughtException', async (error) => {
+process.on('uncaughtException', (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
- await shutdown(1);
+ shutdown(1);
});
// register shutdown signals
-process.once('SIGHUP', async () => shutdown(0));
-process.once('SIGINT', async () => shutdown(0));
-process.once('SIGTERM', async () => shutdown(0));
+process.once('SIGHUP', () => shutdown(0));
+process.once('SIGINT', () => shutdown(0));
+process.once('SIGTERM', () => shutdown(0));
diff --git a/apps/server/src/classes/Logger.ts b/apps/server/src/classes/Logger.ts
index 5c21d77e05..27cb734f3c 100644
--- a/apps/server/src/classes/Logger.ts
+++ b/apps/server/src/classes/Logger.ts
@@ -1,9 +1,9 @@
import { Log, LogLevel } from 'ontime-types';
import { generateId, millisToString } from 'ontime-utils';
-import { clock } from '../services/Clock.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleError } from '../utils/console.js';
+import { timeNow } from '../utils/time.js';
import { isProduction } from '../externals.js';
class Logger {
@@ -75,7 +75,7 @@ class Logger {
level,
origin,
text,
- time: millisToString(clock.getSystemTime() || 0),
+ time: millisToString(timeNow()),
};
this._push(log);
}
diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts
index d9a79ecdce..fbf7e6a3b4 100644
--- a/apps/server/src/classes/data-provider/DataProvider.ts
+++ b/apps/server/src/classes/data-provider/DataProvider.ts
@@ -1,12 +1,14 @@
import {
ProjectData,
- OntimeRundown,
ViewSettings,
DatabaseModel,
Settings,
CustomFields,
URLPreset,
AutomationSettings,
+ Rundown,
+ ProjectRundowns,
+ LogOrigin,
} from 'ontime-types';
import type { Low } from 'lowdb';
@@ -22,11 +24,39 @@ type ReadonlyPromise = Promise>;
let db = {} as Low;
+import { publicDir } from '../../setup/index.js';
+import { ClassicLevel } from 'classic-level';
+import { logger } from '../Logger.js';
+
+const main_db = new ClassicLevel(`${publicDir.projectsDir}/db`, {
+ valueEncoding: 'json',
+});
+
+const rundown_db = main_db.sublevel('rundowns', {
+ valueEncoding: 'json',
+});
+
+/**
+ * Initialises the JSON adapter to persist data to a file
+ */
export async function initPersistence(filePath: string, fallbackData: DatabaseModel) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
const newDb = await JSONFilePreset(filePath, fallbackData);
+ const { project, settings, viewSettings, urlPresets, customFields, automation, rundowns } = fallbackData;
+ await main_db.open();
+ await main_db.put('project', project);
+ await main_db.put('settings', settings);
+ await main_db.put('viewSettings', viewSettings);
+ await main_db.put('urlPresets', urlPresets);
+ await main_db.put('customFields', customFields);
+ await main_db.put('automation', automation);
+
+ Object.entries(rundowns).forEach(([key, rundown]) => {
+ rundown_db.put(key, rundown);
+ });
+
// Read the database to initialize it
newDb.data = fallbackData;
await newDb.write();
@@ -45,6 +75,7 @@ export function getDataProvider() {
setCustomFields,
getCustomFields,
setRundown,
+ mergeRundown,
getSettings,
setSettings,
getUrlPresets,
@@ -55,6 +86,7 @@ export function getDataProvider() {
setAutomation,
getRundown,
mergeIntoData,
+ shutdown,
};
}
@@ -63,13 +95,13 @@ function getData(): Readonly {
}
async function setProjectData(newData: Partial): ReadonlyPromise {
- db.data.project = { ...db.data.project, ...newData };
- await persist();
- return db.data.project;
+ const newProjectData = { ...getProjectData(), ...newData };
+ await main_db.put('project', newProjectData);
+ return newProjectData;
}
-function getProjectData(): Readonly {
- return db.data.project;
+function getProjectData(): ProjectData {
+ return main_db.getSync('project') as ProjectData;
}
async function setCustomFields(newData: CustomFields): ReadonlyPromise {
@@ -78,18 +110,32 @@ async function setCustomFields(newData: CustomFields): ReadonlyPromise {
- return db.data.customFields;
+async function mergeRundown(
+ newCustomFields: CustomFields,
+ newRundowns: ProjectRundowns,
+): ReadonlyPromise<{ rundowns: ProjectRundowns; customFields: CustomFields }> {
+ db.data.customFields = { ...db.data.customFields, ...newCustomFields };
+
+ Object.entries(newRundowns).forEach(([id, rundown]) => {
+ // Note that entries with the same key will be overridden
+ db.data.rundowns[id] = rundown;
+ });
+ await persist();
+ return { rundowns: db.data.rundowns, customFields: db.data.customFields };
+}
+
+function getCustomFields(): CustomFields {
+ return main_db.getSync('customFields') as CustomFields;
}
-async function setRundown(newData: OntimeRundown): ReadonlyPromise