From 5fafa08723f5c4a3c95af4a1669b0e0d638a4f64 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 7 Mar 2025 23:02:32 +0100 Subject: [PATCH 01/49] refactor: remove unused and legacy code - remove legacy migrations - remove unused server code - remove unused UI code --- apps/client/src/common/api/constants.ts | 8 +- .../components/context-menu/ContextMenu.tsx | 2 +- .../error-boundary/ErrorBoundary.jsx | 6 +- .../loader-overlay/LoaderOverlay.module.scss | 45 -- .../loader-overlay/LoaderOverlay.tsx | 9 - .../ProductionNavigationMenu.tsx | 16 - .../components/view-params-editor/types.ts | 2 +- .../src/common/context/useMediaQuery.ts | 32 -- .../hooks-query/useAutomationSettings.ts | 18 +- .../src/common/hooks-query/useProjectList.ts | 2 +- apps/client/src/common/hooks/useMemoisedFn.ts | 2 +- apps/client/src/common/hooks/useSocket.ts | 10 - apps/client/src/common/stores/logger.ts | 2 +- apps/client/src/common/utils/socket.ts | 25 +- apps/client/src/common/utils/time.ts | 2 +- apps/client/src/declarations/test.d.ts | 11 - .../panel/general-panel/GeneralPanelForm.tsx | 4 - .../import-map/importMapUtils.ts | 2 +- .../rundown/event-editor/EventEditor.tsx | 2 - .../src/features/rundown/useEventSelection.ts | 2 +- .../src/features/viewers/common/animation.ts | 15 - .../src/translation/TranslationProvider.tsx | 2 +- .../timeline-section/TimelineSection.tsx | 2 +- .../src/views/timeline/timeline.utils.ts | 8 - apps/server/src/adapters/WebsocketAdapter.ts | 2 +- .../server/src/api-data/excel/excel.router.ts | 2 - .../api-data/rundown/rundown.controller.ts | 44 +- .../src/api-data/rundown/rundown.router.ts | 3 - .../api-data/rundown/rundown.validation.ts | 13 +- .../__tests__/integration.legacy.test.ts | 36 -- .../api-integration/integration.controller.ts | 9 +- .../src/api-integration/integration.legacy.ts | 67 --- apps/server/src/app.ts | 2 +- apps/server/src/classes/Logger.ts | 4 +- apps/server/src/services/Clock.ts | 60 --- .../aux-timer-service/AuxTimerService.ts | 4 +- .../__tests__/rundownCache.test.ts | 407 ------------------ .../__tests__/rundownUtils.test.ts | 39 -- .../services/rundown-service/delayUtils.ts | 90 +--- .../rundown-service/rundownCacheUtils.ts | 6 +- .../services/rundown-service/rundownUtils.ts | 16 - apps/server/src/stores/runtimeState.ts | 14 +- .../utils/__tests__/parserFunctions.test.ts | 46 -- apps/server/src/utils/parser.ts | 2 +- apps/server/src/utils/parserFunctions.ts | 22 +- apps/server/src/utils/removeFileExtension.ts | 9 - apps/server/src/utils/time.ts | 14 + .../ontime-controller/BackendResponse.type.ts | 6 - packages/types/src/index.ts | 1 - 49 files changed, 64 insertions(+), 1083 deletions(-) delete mode 100644 apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss delete mode 100644 apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx delete mode 100644 apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx delete mode 100644 apps/client/src/common/context/useMediaQuery.ts delete mode 100644 apps/client/src/declarations/test.d.ts delete mode 100644 apps/client/src/features/viewers/common/animation.ts delete mode 100644 apps/server/src/api-integration/__tests__/integration.legacy.test.ts delete mode 100644 apps/server/src/api-integration/integration.legacy.ts delete mode 100644 apps/server/src/services/Clock.ts delete mode 100644 apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts delete mode 100644 apps/server/src/utils/removeFileExtension.ts diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index 52f2812117..a5968445c7 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -10,7 +10,6 @@ export const PROJECT_DATA = ['project']; export const PROJECT_LIST = ['projectList']; export const RUNDOWN = ['rundown']; export const RUNTIME = ['runtimeStore']; -export const SHEET_STATE = ['sheetState']; export const URL_PRESETS = ['urlpresets']; export const VIEW_SETTINGS = ['viewSettings']; export const CLIENT_LIST = ['clientList']; @@ -19,11 +18,8 @@ export const REPORT = ['report']; // API URLs export const apiEntryUrl = `${serverURL}/data`; -export const projectDataURL = `${serverURL}/project`; -export const rundownURL = `${serverURL}/events`; -export const ontimeURL = `${serverURL}/ontime`; +const userAssetsPath = 'user'; +const cssOverridePath = 'styles/override.css'; -export const userAssetsPath = 'user'; -export const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; diff --git a/apps/client/src/common/components/context-menu/ContextMenu.tsx b/apps/client/src/common/components/context-menu/ContextMenu.tsx index 3ed5a842a6..331532ee46 100644 --- a/apps/client/src/common/components/context-menu/ContextMenu.tsx +++ b/apps/client/src/common/components/context-menu/ContextMenu.tsx @@ -23,7 +23,7 @@ export type OptionWithoutGroup = { withDivider?: boolean; }; -export type OptionWithGroup = { +type OptionWithGroup = { label: string; group: Omit[]; }; diff --git a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx index f0d3e94773..0cf4d228ee 100644 --- a/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx +++ b/apps/client/src/common/components/error-boundary/ErrorBoundary.jsx @@ -3,8 +3,8 @@ import React from 'react'; // skipcq: JS-C1003 - sentry does not expose itself as an ES Module. import * as Sentry from '@sentry/react'; -import { runtimeStore } from '@/common/stores/runtime'; -import { hasConnected, reconnectAttempts, shouldReconnect } from '@/common/utils/socket'; +import { hasConnected, reconnectAttempts } from '../../../common/utils/socket'; +import { runtimeStore } from '../../stores/runtime'; import style from './ErrorBoundary.module.scss'; @@ -37,7 +37,7 @@ class ErrorBoundary extends React.Component { scope.setExtras({ error, store: appState, - hasSocket: { hasConnected, shouldReconnect, reconnectAttempts }, + hasSocket: { hasConnected, reconnectAttempts }, }); const eventId = Sentry.captureException(error); this.setState({ eventId, info }); diff --git a/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss b/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss deleted file mode 100644 index 074e32f2f0..0000000000 --- a/apps/client/src/common/components/loader-overlay/LoaderOverlay.module.scss +++ /dev/null @@ -1,45 +0,0 @@ -$loader-size: 4rem; - -.overlay { - position: absolute; - z-index: 10; - width: 100%; - height: 100%; - display: grid; - place-content: center; - background-color: $black-10; - backdrop-filter: blur(5px); -} - - -.loader { - width: $loader-size; - height: $loader-size; - background: $blue-500; - display: inline-block; - border-radius: 50%; - box-sizing: border-box; - animation: animloader 1s ease-in infinite; -} - -@keyframes animloader { - 0% { - transform: scale(0); - opacity: 0.6; - } - 100% { - transform: scale(1); - opacity: 0; - } -} - -@keyframes animloader { - 0% { - transform: scale(0); - opacity: 0.6; - } - 100% { - transform: scale(1); - opacity: 0; - } -} diff --git a/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx b/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx deleted file mode 100644 index bc77a28143..0000000000 --- a/apps/client/src/common/components/loader-overlay/LoaderOverlay.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import style from './LoaderOverlay.module.scss'; - -export default function LoaderOverlay() { - return ( -
- -
- ); -} diff --git a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx deleted file mode 100644 index 2841ba0692..0000000000 --- a/apps/client/src/common/components/navigation-menu/ProductionNavigationMenu.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { memo } from 'react'; - -import NavigationMenu from './NavigationMenu'; - -interface ProductionNavigationMenuProps { - isMenuOpen: boolean; - onMenuClose: () => void; -} - -function ProductionNavigationMenu(props: ProductionNavigationMenuProps) { - const { isMenuOpen, onMenuClose } = props; - - return ; -} - -export default memo(ProductionNavigationMenu); diff --git a/apps/client/src/common/components/view-params-editor/types.ts b/apps/client/src/common/components/view-params-editor/types.ts index 51d8a2ad1d..84ab3ff689 100644 --- a/apps/client/src/common/components/view-params-editor/types.ts +++ b/apps/client/src/common/components/view-params-editor/types.ts @@ -12,7 +12,7 @@ type OptionsField = { defaultValue?: string; }; -export type MultiselectOption = { value: string; label: string; colour: string }; +type MultiselectOption = { value: string; label: string; colour: string }; export type MultiselectOptions = Record; type MultiOptionsField = { type: 'multi-option'; diff --git a/apps/client/src/common/context/useMediaQuery.ts b/apps/client/src/common/context/useMediaQuery.ts deleted file mode 100644 index e2d282529f..0000000000 --- a/apps/client/src/common/context/useMediaQuery.ts +++ /dev/null @@ -1,32 +0,0 @@ -// roughly from https://github.com/juliencrn/usehooks-ts/blob/master/packages/usehooks-ts/src/useMediaQuery/useMediaQuery.ts - -import { useCallback, useEffect, useState } from 'react'; - -function getMatches(query: string): boolean { - return window.matchMedia(query).matches; -} - -// TODO: debounce handleChange -export default function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(getMatches(query)); - - const handleChange = useCallback(() => { - setMatches(getMatches(query)); - }, [query]); - - useEffect(() => { - const matchMedia = window.matchMedia(query); - - // Triggered at the first client-side load and if query changes - handleChange(); - - // Listen matchMedia - matchMedia.addEventListener('change', handleChange); - - return () => { - matchMedia.removeEventListener('change', handleChange); - }; - }, [handleChange, query]); - - return matches; -} diff --git a/apps/client/src/common/hooks-query/useAutomationSettings.ts b/apps/client/src/common/hooks-query/useAutomationSettings.ts index 60fffa64bb..18d9f81ab7 100644 --- a/apps/client/src/common/hooks-query/useAutomationSettings.ts +++ b/apps/client/src/common/hooks-query/useAutomationSettings.ts @@ -1,11 +1,9 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; -import { editAutomationSettings, getAutomationSettings } from '../api/automation'; +import { getAutomationSettings } from '../api/automation'; import { AUTOMATION } from '../api/constants'; -import { logAxiosError } from '../api/utils'; import { automationPlaceholderSettings } from '../models/AutomationSettings'; -import { ontimeQueryClient } from '../queryClient'; export default function useAutomationSettings() { const { data, status, isFetching, isError, refetch } = useQuery({ @@ -20,15 +18,3 @@ export default function useAutomationSettings() { return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch }; } - -export function useAutomationSettingsMutation() { - const { isPending, mutateAsync } = useMutation({ - mutationFn: editAutomationSettings, - onError: (error) => logAxiosError('Error saving Automation settings', error), - onSuccess: (data) => { - ontimeQueryClient.setQueryData(AUTOMATION, data); - }, - onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }), - }); - return { isPending, mutateAsync }; -} diff --git a/apps/client/src/common/hooks-query/useProjectList.ts b/apps/client/src/common/hooks-query/useProjectList.ts index 57d3b6f012..329bba44ba 100644 --- a/apps/client/src/common/hooks-query/useProjectList.ts +++ b/apps/client/src/common/hooks-query/useProjectList.ts @@ -11,7 +11,7 @@ const placeholderProjectList: ProjectFileListResponse = { lastLoadedProject: '', }; -export function useProjectList() { +function useProjectList() { const { data, status, refetch } = useQuery({ queryKey: PROJECT_LIST, queryFn: getProjects, diff --git a/apps/client/src/common/hooks/useMemoisedFn.ts b/apps/client/src/common/hooks/useMemoisedFn.ts index 179c00e0c2..b1a1545b5d 100644 --- a/apps/client/src/common/hooks/useMemoisedFn.ts +++ b/apps/client/src/common/hooks/useMemoisedFn.ts @@ -12,7 +12,7 @@ type noop = (this: any, ...args: any[]) => any; type PickFunction = (this: ThisParameterType, ...args: Parameters) => ReturnType; -export const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; +const isFunction = (value: unknown): value is (...args: any) => any => typeof value === 'function'; export default function useMemoisedFn(fn: T) { if (isDev) { diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 6bbd8634f1..973c8b0b80 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -91,14 +91,6 @@ export const setPlayback = { }, }; -export const useInfoPanel = createSelector((state: RuntimeStore) => ({ - eventNow: state.eventNow, - eventNext: state.eventNext, - playback: state.timer.playback, - selectedEventIndex: state.runtime.selectedEventIndex, - numEvents: state.runtime.numEvents, -})); - export const useAuxTimerTime = createSelector((state: RuntimeStore) => state.auxtimer1.current); export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ @@ -145,8 +137,6 @@ export const useProgressData = createSelector((state: RuntimeStore) => ({ timeDanger: state.eventNow?.timeDanger ?? null, })); -export const setClientName = (newName: string) => socketSendJson('set-client-name', newName); - export const useRuntimeOverview = createSelector((state: RuntimeStore) => ({ plannedStart: state.runtime.plannedStart, actualStart: state.runtime.actualStart, diff --git a/apps/client/src/common/stores/logger.ts b/apps/client/src/common/stores/logger.ts index fb60d07c4d..3e919cf46b 100644 --- a/apps/client/src/common/stores/logger.ts +++ b/apps/client/src/common/stores/logger.ts @@ -11,7 +11,7 @@ type LogStore = { logs: Log[]; }; -export const logger = createStore(() => ({ +const logger = createStore(() => ({ logs: [], })); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 6fd92475c1..dd77cadf54 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -16,11 +16,10 @@ import { addDialog } from '../stores/dialogStore'; import { addLog } from '../stores/logger'; import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime'; -export let websocket: WebSocket | null = null; +let websocket: WebSocket | null = null; let reconnectTimeout: NodeJS.Timeout | null = null; const reconnectInterval = 1000; -export let shouldReconnect = true; export let hasConnected = false; export let reconnectAttempts = 0; @@ -49,18 +48,17 @@ export const connectSocket = () => { websocket.onclose = () => { console.warn('WebSocket disconnected'); - if (shouldReconnect) { - reconnectTimeout = setTimeout(() => { + // we decide to allows reconnect + reconnectTimeout = setTimeout(() => { if (reconnectAttempts > 2) { setOnlineStatus(false); } - console.warn('WebSocket: attempting reconnect'); - if (websocket && websocket.readyState === WebSocket.CLOSED) { - reconnectAttempts += 1; - connectSocket(); - } - }, reconnectInterval); - } + console.warn('WebSocket: attempting reconnect'); + if (websocket && websocket.readyState === WebSocket.CLOSED) { + reconnectAttempts += 1; + connectSocket(); + } + }, reconnectInterval); }; websocket.onerror = (error) => { @@ -226,11 +224,6 @@ export const connectSocket = () => { }; }; -export const disconnectSocket = () => { - shouldReconnect = false; - websocket?.close(); -}; - export const socketSend = (message: any) => { if (websocket && websocket.readyState === WebSocket.OPEN) { websocket.send(message); diff --git a/apps/client/src/common/utils/time.ts b/apps/client/src/common/utils/time.ts index fc1c42dce5..0778fe1f6f 100644 --- a/apps/client/src/common/utils/time.ts +++ b/apps/client/src/common/utils/time.ts @@ -35,7 +35,7 @@ function getFormatFromParams() { * Gets the format options from the applicaton settings * @returns a string equivalent to the format, ie: hh:mm:ss a or HH:mm:ss */ -export function getFormatFromSettings(): TimeFormat { +function getFormatFromSettings(): TimeFormat { const settings: Settings | undefined = ontimeQueryClient.getQueryData(APP_SETTINGS); return settings?.timeFormat ?? '24'; } diff --git a/apps/client/src/declarations/test.d.ts b/apps/client/src/declarations/test.d.ts deleted file mode 100644 index bf28ed90f2..0000000000 --- a/apps/client/src/declarations/test.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'; - -import 'vitest'; - -// ugly hack because vite and pnpm are not playing ball with jest -// https://github.com/testing-library/jest-dom/issues/123 -declare global { - namespace Vi { - type Assertion = TestingLibraryMatchers; - } -} diff --git a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx index 47fca788cf..e041094c19 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/GeneralPanelForm.tsx @@ -13,10 +13,6 @@ import * as Panel from '../../panel-utils/PanelUtils'; import GeneralPinInput from './GeneralPinInput'; -export type GeneralPanelFormValues = { - filename: string; -}; - export default function GeneralPanelForm() { const { data, status, refetch } = useSettings(); const { diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts index 0f786355f1..3a1d4c48f7 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts @@ -3,7 +3,7 @@ import { ImportCustom, ImportMap } from 'ontime-utils'; export type NamedImportMap = typeof namedImportMap; // Record of label and import name -export const namedImportMap = { +const namedImportMap = { Worksheet: 'event schedule', Start: 'time start', 'Link start': 'link start', diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index d094e891fc..007dca5b23 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -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 { diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index 8602a859c1..d286abb719 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -6,7 +6,7 @@ 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; 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/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/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..fd43ffd2e6 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -11,7 +11,6 @@ import { 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 */ diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index 5e16796dd6..849c0884a1 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; 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/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index 3575ebc510..f40ab53b6b 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, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; @@ -21,12 +14,7 @@ import { reorderEvent, swapEvents, } from '../../services/rundown-service/RundownService.js'; -import { - getEventWithId, - getNormalisedRundown, - getPaginated, - getRundown, -} from '../../services/rundown-service/rundownUtils.js'; +import { getEventWithId, getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js'; export async function rundownGetAll(_req: Request, res: Response) { const rundown = getRundown(); @@ -55,34 +43,6 @@ 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) { if (failEmptyObjects(req.body, res)) { return; diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index ebac101cbe..38b6cd15a7 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -8,7 +8,6 @@ import { rundownGetAll, rundownGetById, rundownGetNormalised, - rundownGetPaginated, rundownPost, rundownPut, rundownReorder, @@ -18,7 +17,6 @@ import { paramsMustHaveEventId, rundownArrayOfIds, rundownBatchPutValidator, - rundownGetPaginatedQueryParams, rundownPostValidator, rundownPutValidator, rundownReorderValidator, @@ -28,7 +26,6 @@ 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 diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index d815bb5766..1f6c1b17ab 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 = [ @@ -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-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..cbab39b4d3 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -15,8 +15,6 @@ 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 { 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); 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/app.ts b/apps/server/src/app.ts index 5dda9aa1e1..1055e3a1ea 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -245,7 +245,7 @@ export const startIntegrations = async () => { * @param {number} exitCode * @return {Promise} */ -export const shutdown = async (exitCode = 0) => { +const shutdown = async (exitCode = 0) => { consoleHighlight(`Ontime shutting down with code ${exitCode}`); // clear the restore file if it was a normal exit 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/services/Clock.ts b/apps/server/src/services/Clock.ts deleted file mode 100644 index c19bad194b..0000000000 --- a/apps/server/src/services/Clock.ts +++ /dev/null @@ -1,60 +0,0 @@ -enum Source { - System = 'system', - MIDI = 'MIDI', -} - -/** - * Service manages retrieving current time from a managed time source - */ -class Clock { - private static instance: Clock; - private readonly source: Source; - - constructor(source?: Source) { - if (Clock.instance) { - return Clock.instance; - } - - Clock.instance = this; - - this.source = source || Source.System; - } - - /** - * Get current time from source - */ - timeNow(): number { - switch (this.source) { - case Source.System: - return this.getSystemTime(); - case Source.MIDI: - // @ts-expect-error -- not implemented - return this.getMidiTime(); - default: - throw new Error('Invalid time source'); - } - } - - /** - * Get current time from system - */ - getSystemTime() { - const now = new Date(); - - // extract milliseconds since midnight - let elapsed = now.getHours() * 3600000; - elapsed += now.getMinutes() * 60000; - elapsed += now.getSeconds() * 1000; - elapsed += now.getMilliseconds(); - return elapsed; - } - - /** - * Get current time from MIDI - */ - getMidiTime() { - throw new Error('Not implemented'); - } -} - -export const clock = new Clock(); diff --git a/apps/server/src/services/aux-timer-service/AuxTimerService.ts b/apps/server/src/services/aux-timer-service/AuxTimerService.ts index e0b4c59e58..7b04ae4f7d 100644 --- a/apps/server/src/services/aux-timer-service/AuxTimerService.ts +++ b/apps/server/src/services/aux-timer-service/AuxTimerService.ts @@ -4,8 +4,8 @@ import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; import { eventStore } from '../../stores/EventStore.js'; import { timerConfig } from '../../config/config.js'; -export type EmitFn = (state: SimpleTimerState) => void; -export type GetTimeFn = () => number; +type EmitFn = (state: SimpleTimerState) => void; +type GetTimeFn = () => number; export class AuxTimerService { private timer: SimpleTimer; diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 3e96845871..ce4dbec6a5 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -1,6 +1,5 @@ import { CustomFields, - EndAction, EventCustomFields, OntimeBlock, OntimeDelay, @@ -8,11 +7,9 @@ import { OntimeRundown, SupportedEvent, TimeStrategy, - TimerType, } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; -import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js'; import { add, batchEdit, @@ -557,410 +554,6 @@ describe('swap() mutation', () => { }); }); -describe('calculateRuntimeDelays', () => { - it('calculates all delays in a given rundown', () => { - const rundown: OntimeRundown = [ - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '659e1', - cue: '1', - custom: {}, - }, - { - duration: 600000, - type: SupportedEvent.Delay, - id: '07986', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1200000, - duration: 0, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '1c48f', - cue: '2', - custom: {}, - }, - { - duration: 1200000, - type: SupportedEvent.Delay, - id: '7db42', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: 'd48c2', - cue: '3', - custom: {}, - }, - { - title: '', - type: SupportedEvent.Block, - id: '9870d', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1800000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '2f185', - cue: '4', - custom: {}, - }, - ]; - - const updatedRundown = calculateRuntimeDelays(rundown); - - expect(rundown.length).toBe(updatedRundown.length); - expect((updatedRundown[0] as OntimeEvent).delay).toBe(0); - expect((updatedRundown[2] as OntimeEvent).delay).toBe(600000); - expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000); - expect((updatedRundown[6] as OntimeEvent).delay).toBe(0); - }); -}); - -describe('getDelayAt()', () => { - const delayedRundown: OntimeRundown = [ - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '659e1', - delay: 0, - dayOffset: 0, - gap: 0, - cue: '1', - custom: {}, - }, - { - duration: 600000, - type: SupportedEvent.Delay, - id: '07986', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1200000, - duration: 0, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '1c48f', - delay: 600000, - cue: '2', - custom: {}, - }, - { - duration: 1200000, - type: SupportedEvent.Delay, - id: '7db42', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: 'd48c2', - delay: 1800000, - cue: '3', - custom: {}, - }, - { - title: '', - type: SupportedEvent.Block, - id: '9870d', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1800000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '2f185', - delay: 0, - cue: '4', - custom: {}, - }, - ]; - - it('calculates delay in a rundown', () => { - const delayAtStart = getDelayAt(0, delayedRundown); - const delayOnFirstEvent = getDelayAt(2, delayedRundown); - const delayOnSecondEvent = getDelayAt(4, delayedRundown); - const delayOnBlockedEvent = getDelayAt(0, delayedRundown); - - expect(delayAtStart).toBe(0); - expect(delayOnFirstEvent).toBe(600000); - expect(delayOnSecondEvent).toBe(600000 + 1200000); - expect(delayOnBlockedEvent).toBe(0); - }); - it('finds delay before a delay block', () => { - const valueOnFirstDelayBlock = getDelayAt(1, delayedRundown); - const valueOnSecondDelayBlock = getDelayAt(3, delayedRundown); - const valueAfterSecondDelayBlock = getDelayAt(4, delayedRundown); - - expect(valueOnFirstDelayBlock).toBe(0); - expect(valueOnSecondDelayBlock).toBe(600000); - expect(valueAfterSecondDelayBlock).toBe(600000 + 1200000); - }); - it('returns 0 after blocks', () => { - const valueOnBlock = getDelayAt(6, delayedRundown); - expect(valueOnBlock).toBe(0); - }); -}); - -describe('calculateRuntimeDelaysFrom()', () => { - it('updates delays from given id', () => { - const delayedRundown: OntimeRundown = [ - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '659e1', - delay: 0, - cue: '1', - custom: {}, - }, - { - duration: 600000, - type: SupportedEvent.Delay, - id: '07986', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1200000, - duration: 0, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '1c48f', - delay: 0, - cue: '2', - custom: {}, - }, - { - duration: 1200000, - type: SupportedEvent.Delay, - id: '7db42', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 600000, - timeEnd: 1200000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: 'd48c2', - delay: 1800000, - cue: '3', - custom: {}, - }, - { - title: '', - type: SupportedEvent.Block, - id: '9870d', - }, - { - title: '', - note: '', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - timeStart: 1200000, - timeEnd: 1800000, - duration: 600000, - isPublic: true, - skip: false, - colour: '', - type: SupportedEvent.Event, - revision: 0, - dayOffset: 0, - gap: 0, - timeWarning: 120000, - timeDanger: 60000, - id: '2f185', - delay: 0, - cue: '4', - custom: {}, - }, - ]; - - const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown); - - // we only update from the 4th on - expect((updatedRundown[0] as OntimeEvent).delay).toBe(0); - // 1 + 3 - expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000); - }); -}); - describe('custom fields', () => { describe('createCustomField()', () => { it('creates a field from given parameters', () => { diff --git a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts deleted file mode 100644 index e917d8f6a2..0000000000 --- a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { OntimeRundown } from 'ontime-types'; -import { getPaginated } from '../rundownUtils.js'; - -describe('getPaginated', () => { - // mock cache so we dont run data functions - beforeAll(() => { - vi.mock('../rundownCache.js', () => ({})); - }); - - // @ts-expect-error -- we know this is not correct, but good enough for the test - const getData = () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as OntimeRundown; - - it('should return the correct paginated rundown', () => { - const offset = 0; - const limit = 1; - const result = getPaginated(offset, limit, getData); - - expect(result.rundown).toHaveLength(1); - expect(result.total).toBe(10); - }); - - it('should handle overflows', () => { - const offset = 0; - const limit = 20; - const result = getPaginated(offset, limit, getData); - - expect(result.rundown).toHaveLength(10); - expect(result.total).toBe(10); - }); - - it('should handle out of range', () => { - const offset = 11; - const limit = Infinity; - const result = getPaginated(offset, limit, getData); - - expect(result.rundown).toHaveLength(0); - expect(result.total).toBe(10); - }); -}); diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index f625263fd2..f5264dc4d8 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,94 +1,6 @@ -import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types'; +import { OntimeRundown, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { deleteAtIndex } from 'ontime-utils'; -/** - * Calculates all delays in a given rundown - * @param rundown - */ -export function calculateRuntimeDelays(rundown: OntimeRundown) { - let accumulatedDelay = 0; - const updatedRundown = [...rundown]; - - for (const [index, event] of updatedRundown.entries()) { - if (isOntimeDelay(event)) { - accumulatedDelay += event.duration; - } else if (isOntimeBlock(event)) { - accumulatedDelay = 0; - } else if (isOntimeEvent(event)) { - updatedRundown[index] = { - ...event, - delay: accumulatedDelay, - }; - } - } - return updatedRundown; -} -/** - * Calculate delays in rundown from a given index - * @param eventIndex - * @param rundown - */ -export function calculateRuntimeDelaysFromIndex(eventIndex: number, rundown: OntimeRundown) { - if (eventIndex === -1) { - throw new Error('ID not found at index'); - } - - let accumulatedDelay = getDelayAt(eventIndex, rundown); - const updatedRundown = [...rundown]; - - for (let i = eventIndex; i < rundown.length; i++) { - const event = rundown[i]; - if (isOntimeDelay(event)) { - accumulatedDelay += event.duration; - } else if (isOntimeBlock(event)) { - if (i === eventIndex) { - accumulatedDelay = 0; - } else { - break; - } - } else if (isOntimeEvent(event)) { - updatedRundown[i] = { - ...event, - delay: accumulatedDelay, - }; - } - } - return updatedRundown; -} - -/** - * Calculate delays in rundown from an event with given id - * @param eventId - * @param rundown - */ -export function calculateRuntimeDelaysFrom(eventId: string, rundown: OntimeRundown) { - const index = rundown.findIndex((event) => event.id === eventId); - return calculateRuntimeDelaysFromIndex(index, rundown); -} - -/** - * Calculates delay to an event at a given index - * @param eventIndex - * @param rundown - */ -export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number { - if (eventIndex < 1) { - return 0; - } - - // we need to check the event before - const event = rundown[eventIndex - 1]; - - if (isOntimeDelay(event)) { - return event.duration + getDelayAt(eventIndex - 1, rundown); - } else if (isOntimeBlock(event)) { - return 0; - } else if (isOntimeEvent(event)) { - return event.delay ?? 0; - } - return 0; -} - /** * Applies delay from given event ID, deletes the delay event after * @throws {Error} if event ID not found or is not a delay diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts index 9089c90a14..a1cbe75669 100644 --- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts +++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts @@ -105,7 +105,7 @@ export function handleCustomField( } /** List of event properties which do not need the rundown to be regenerated */ -export enum regenerateWhitelist { +enum RegenerateWhitelist { 'id', 'cue', 'title', @@ -126,7 +126,7 @@ export enum regenerateWhitelist { * @param path */ export function isDataStale(patch: Partial): boolean { - return Object.keys(patch).some((key) => !(key in regenerateWhitelist)); + return Object.keys(patch).some((key) => !(key in RegenerateWhitelist)); } /** @@ -134,7 +134,7 @@ export function isDataStale(patch: Partial): boolean { * @param path */ export function willCauseRegeneration(key: keyof OntimeEvent): boolean { - return !(key in regenerateWhitelist); + return !(key in RegenerateWhitelist); } /** diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index 44021a7334..b006681867 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -101,19 +101,3 @@ export function findNext(currentEventId?: string): PlayableEvent | null { const nextEvent = playableEvents.at(newIndex); return nextEvent ?? null; } - -/** - * Returns a paginated rundown - * Exposes a getter function for the rundown for testing - */ -export function getPaginated( - offset: number, - limit: number, - source = getRundown, -): { rundown: OntimeRundownEntry[]; total: number } { - const rundown = source(); - return { - rundown: rundown.slice(Math.min(offset, rundown.length), Math.min(offset + limit, rundown.length)), - total: rundown.length, - }; -} diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 50aac59b24..1eaacd8d8f 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -22,7 +22,7 @@ import { isPlaybackActive, } from 'ontime-utils'; -import { clock } from '../services/Clock.js'; +import { timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/RestoreService.js'; import { getCurrent, @@ -55,7 +55,7 @@ export type RuntimeState = { }; const runtimeState: RuntimeState = { - clock: clock.timeNow(), + clock: timeNow(), currentBlock: { ...runtimeStorePlaceholder.currentBlock }, eventNow: null, publicEventNow: null, @@ -130,7 +130,7 @@ export function clearState() { runtimeState.runtime.selectedEventIndex = null; runtimeState.timer.playback = Playback.Stop; - runtimeState.clock = clock.timeNow(); + runtimeState.clock = timeNow(); runtimeState.timer = { ...runtimeStorePlaceholder.timer }; // when clearing, we maintain the total delay from the rundown @@ -388,7 +388,7 @@ export function start(state: RuntimeState = runtimeState): boolean { if (state.timer.playback === Playback.Play) { return false; } - state.clock = clock.timeNow(); + state.clock = timeNow(); state.timer.secondaryTimer = null; // add paused time if it exists @@ -433,7 +433,7 @@ export function pause(state: RuntimeState = runtimeState): boolean { } state.timer.playback = Playback.Pause; - state.clock = clock.timeNow(); + state.clock = timeNow(); state._timer.pausedAt = state.clock; return true; } @@ -469,7 +469,7 @@ export function addTime(amount: number) { if (willGoNegative && !hasFinished) { // set finished time so side effects are triggered - runtimeState._timer.forceFinish = clock.timeNow(); + runtimeState._timer.forceFinish = timeNow(); } else { const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0; if (willGoPositive) { @@ -499,7 +499,7 @@ export type UpdateResult = { export function update(): UpdateResult { // 0. there are some things we always do const previousClock = runtimeState.clock; - runtimeState.clock = clock.timeNow(); // we update the clock on every update call + runtimeState.clock = timeNow(); // we update the clock on every update call // 1. is playback idle? if (!isPlaybackActive(runtimeState.timer.playback)) { diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index 0121d1c6d4..03b39f46f4 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -1,13 +1,10 @@ import { CustomFields, DatabaseModel, - EndAction, OntimeEvent, OntimeRundown, Settings, SupportedEvent, - TimeStrategy, - TimerType, URLPreset, } from 'ontime-types'; @@ -360,46 +357,3 @@ describe('parseRundown() linking', () => { }); }); }); - -describe('parseRundown() migrations', () => { - const legacyEvent = { - id: '1', - type: SupportedEvent.Event, - cue: '', - title: '', - note: '', - endAction: EndAction.None, - timerType: 'time-to-end', - linkStart: null, - timeStrategy: TimeStrategy.LockDuration, - timeStart: 0, - timeEnd: 0, - duration: 0, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 120000, - timeDanger: 60000, - custom: {}, - }; - - it('migrates an event with time-to-end', () => { - const result = parseRundown({ rundown: [legacyEvent] as OntimeRundown }); - expect(result.rundown[0]).toMatchObject({ - id: '1', - timerType: TimerType.CountDown, - countToEnd: true, - }); - }); - - it('migrates an event without time-to-end', () => { - const countdownEvent = { ...legacyEvent, timerType: TimerType.CountDown }; - const result = parseRundown({ rundown: [countdownEvent] as OntimeRundown }); - expect(result.rundown[0]).toMatchObject({ - id: '1', - timerType: TimerType.CountDown, - countToEnd: false, - }); - }); -}); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index b4eec3048e..ed7afd908d 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -305,7 +305,7 @@ export const parseExcel = ( }; }; -export type ParsingError = { +type ParsingError = { context: string; message: string; }; diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 7c8cce9d51..3f4d1f3bf5 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -8,7 +8,6 @@ import { OntimeRundown, ProjectData, Settings, - TimerType, URLPreset, ViewSettings, isOntimeBlock, @@ -53,7 +52,7 @@ export function parseRundown( let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; if (isOntimeEvent(event)) { - const maybeEvent = runEventMigrations({ ...event, id }); + const maybeEvent = { ...event, id }; if (event.linkStart) { maybeEvent.linkStart = previousId; @@ -246,22 +245,3 @@ export function sanitiseCustomFields(data: object): CustomFields { return newCustomFields; } - -/** - * Time to end was moved from a TimerType to a standalone boolean named count to end - * Released as part of v3.10.0 - */ -function migrateTimeToEnd(event: any): OntimeEvent { - if (event.timerType === 'time-to-end') { - event.timerType = TimerType.CountDown; - event.countToEnd = true; - } - return event; -} - -/** - * Mutating function migrates event data entries - */ -function runEventMigrations(event: any): OntimeEvent { - return migrateTimeToEnd(event); -} diff --git a/apps/server/src/utils/removeFileExtension.ts b/apps/server/src/utils/removeFileExtension.ts deleted file mode 100644 index 4c577950f8..0000000000 --- a/apps/server/src/utils/removeFileExtension.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { parse } from 'path'; - -/** - * @description Takes a filename and removes the extension - * @param {string} filename - filename with extension - */ -export const removeFileExtension = (filename: string): string => { - return parse(filename).name; -}; diff --git a/apps/server/src/utils/time.ts b/apps/server/src/utils/time.ts index cb9e72e0a8..5b1ffdbfd7 100644 --- a/apps/server/src/utils/time.ts +++ b/apps/server/src/utils/time.ts @@ -60,3 +60,17 @@ export function getTimezoneLabel(date: Date): string { return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`; } + +/** + * Get current time from system + */ +export function timeNow() { + const now = new Date(); + + // extract milliseconds since midnight + let elapsed = now.getHours() * 3600000; + elapsed += now.getMinutes() * 60000; + elapsed += now.getSeconds() * 1000; + elapsed += now.getMilliseconds(); + return elapsed; +} diff --git a/packages/types/src/api/ontime-controller/BackendResponse.type.ts b/packages/types/src/api/ontime-controller/BackendResponse.type.ts index 5770946820..5793798f9c 100644 --- a/packages/types/src/api/ontime-controller/BackendResponse.type.ts +++ b/packages/types/src/api/ontime-controller/BackendResponse.type.ts @@ -1,4 +1,3 @@ -import type { OntimeRundown } from '../../definitions/core/Rundown.type.js'; import type { Playback } from '../../definitions/runtime/Playback.type.js'; import type { MaybeString } from '../../utils/utils.type.js'; @@ -51,8 +50,3 @@ export type ProjectLogoResponse = { export type ErrorResponse = MessageResponse; export type AuthenticationStatus = 'authenticated' | 'not_authenticated' | 'pending'; - -export type RundownPaginated = { - rundown: OntimeRundown; - total: number; -}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 781d09736d..7087d40f30 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -67,7 +67,6 @@ export type { ErrorResponse, ProjectFileListResponse, MessageResponse, - RundownPaginated, SessionStats, ProjectLogoResponse, } from './api/ontime-controller/BackendResponse.type.js'; From 440a9b81b67f7242393f9d07c64834430b5339e4 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 17 Mar 2025 21:47:03 +0100 Subject: [PATCH 02/49] chore: remove IDE files --- .idea/.gitignore | 5 -- .idea/inspectionProfiles/Project_Default.xml | 51 -------------------- 2 files changed, 56 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/inspectionProfiles/Project_Default.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603fea..0000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 1ff6325df5..0000000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - \ No newline at end of file From 6d17f188a603775a0dd88a2798577329b2a43fb9 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 15 Mar 2025 09:04:33 +0100 Subject: [PATCH 03/49] refactor: restructure model to contain an object of rundowns --- apps/client/src/common/api/db.ts | 8 +- apps/client/src/common/api/excel.ts | 11 +- apps/client/src/common/api/rundown.ts | 27 +- apps/client/src/common/api/sheets.ts | 4 +- .../src/common/hooks-query/useRundown.ts | 28 +- .../client/src/common/hooks/useEventAction.ts | 121 +- .../src/common/utils/__tests__/csv.test.ts | 33 +- .../utils/__tests__/eventsManager.test.ts | 4 +- .../common/utils/__tests__/urlPresets.test.ts | 25 +- apps/client/src/common/utils/csv.ts | 27 +- apps/client/src/common/utils/eventsManager.ts | 1 + apps/client/src/common/utils/socket.ts | 4 +- .../feature-settings-panel/ReportSettings.tsx | 4 +- .../reportSettings.utils.ts | 4 +- .../panel/project-panel/project.utils.ts | 2 +- .../panel/sources-panel/ImportReview.tsx | 11 +- .../sources-panel/preview/PreviewRundown.tsx | 63 +- .../panel/sources-panel/useGoogleSheet.ts | 6 +- .../panel/sources-panel/useSheetStore.ts | 8 +- .../client/src/features/operator/Operator.tsx | 6 +- apps/client/src/features/rundown/Rundown.tsx | 63 +- .../src/features/rundown/RundownEntry.tsx | 31 +- .../block-block/BlockBlock.module.scss | 4 - .../rundown/block-block/BlockBlock.tsx | 12 +- .../rundown/block-block/BlockDelete.tsx | 27 - .../event-editor/CuesheetEventEditor.tsx | 7 +- .../event-editor/RundownEventEditor.tsx | 9 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 4 +- .../src/features/rundown/useEventSelection.ts | 10 +- .../features/viewers/countdown/Countdown.tsx | 4 +- .../viewers/countdown/CountdownSelect.tsx | 8 +- .../features/viewers/studio/StudioClock.tsx | 4 +- .../viewers/studio/StudioClockSchedule.tsx | 4 +- .../views/common/schedule/ScheduleContext.tsx | 4 +- .../cuesheet/cuesheet-dnd/CuesheetDnd.tsx | 4 +- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 6 +- .../cuesheet-table-elements/CuesheetBody.tsx | 6 +- .../CuesheetHeader.tsx | 4 +- .../cuesheet-table-elements/EventRow.tsx | 4 +- .../cuesheet-table-elements/SortableCell.tsx | 4 +- .../cuesheet-table-elements/cuesheetCols.tsx | 26 +- .../CuesheetTableSettings.tsx | 4 +- .../cuesheet-table/useColumnManager.tsx | 4 +- .../src/views/cuesheet/cuesheet.utils.ts | 8 +- apps/client/src/views/timeline/Timeline.tsx | 4 +- .../src/views/timeline/timeline.utils.ts | 8 +- .../src/api-data/automation/automation.dao.ts | 7 +- apps/server/src/api-data/db/db.controller.ts | 4 +- apps/server/src/api-data/db/db.middleware.ts | 2 +- apps/server/src/api-data/db/db.validation.ts | 2 +- .../src/api-data/excel/excel.controller.ts | 6 +- .../src/api-data/excel/excel.service.ts | 18 +- .../api-data/rundown/rundown.controller.ts | 22 +- .../src/api-data/rundown/rundown.router.ts | 6 +- .../src/api-data/sheets/sheets.controller.ts | 4 +- .../src/classes/data-provider/DataProvider.ts | 31 +- .../data-provider/DataProvider.utils.ts | 4 +- .../__tests__/DataProvider.utils.test.ts | 150 +- apps/server/src/models/dataModel.ts | 14 +- apps/server/src/models/demoProject.ts | 869 ++++----- apps/server/src/models/eventsDefinition.ts | 26 +- .../project-service/ProjectService.ts | 33 +- .../__tests__/ProjectService.test.ts | 6 +- .../rundown-service/RundownService.ts | 50 +- .../__mocks__/rundown.mocks.ts | 28 +- .../__tests__/delayUtils.test.ts | 388 ++-- .../__tests__/rundownCache.test.ts | 701 ++++---- .../__tests__/rundownCacheUtils.test.ts | 55 +- .../services/rundown-service/delayUtils.ts | 41 +- .../services/rundown-service/rundownCache.ts | 317 ++-- .../rundown-service/rundownCacheUtils.ts | 60 +- .../services/rundown-service/rundownUtils.ts | 138 +- .../runtime-service/RuntimeService.ts | 27 +- .../services/sheet-service/SheetService.ts | 38 +- .../__tests__/sheetUtils.test.ts | 6 + .../src/services/sheet-service/sheetUtils.ts | 8 +- .../src/stores/__tests__/runtimeState.test.ts | 235 +-- apps/server/src/stores/runtimeState.ts | 63 +- .../src/utils/__tests__/parser.mock-data.ts | 59 + .../server/src/utils/__tests__/parser.test.ts | 1579 +++-------------- .../utils/__tests__/parserFunctions.test.ts | 336 ++-- apps/server/src/utils/__tests__/time.test.ts | 72 +- apps/server/src/utils/fileManagement.ts | 2 +- apps/server/src/utils/parser.ts | 117 +- apps/server/src/utils/parserFunctions.ts | 104 +- apps/server/test-db/db.json | 901 +++++----- apps/spec/roll.md | 2 +- e2e/tests/000-upload-showfile.spec.ts | 6 +- e2e/tests/fixtures/e2e-test-db.json | 519 ++++++ e2e/tests/fixtures/test-db.json | 458 ----- package.json | 5 +- .../BackendResponse.type.ts | 20 +- .../types/src/definitions/DataModel.type.ts | 4 +- .../src/definitions/core/CustomFields.type.ts | 2 +- .../src/definitions/core/OntimeEvent.type.ts | 28 +- .../src/definitions/core/Rundown.type.ts | 16 +- packages/types/src/index.ts | 14 +- packages/types/src/utils/guards.ts | 4 +- packages/utils/index.ts | 5 +- packages/utils/src/common/arrayUtils.ts | 5 +- packages/utils/src/common/objectUtils.ts | 4 + packages/utils/src/cue-utils/cueUtils.test.ts | 156 +- packages/utils/src/cue-utils/cueUtils.ts | 75 +- .../src/date-utils/checkIsNextDay.test.ts | 2 +- .../utils/src/date-utils/checkIsNextDay.ts | 2 +- .../src/date-utils/getTimeFromPrevious.ts | 2 +- .../utils/src/date-utils/isNewLatest.test.ts | 2 +- packages/utils/src/date-utils/isNewLatest.ts | 2 +- .../src/rundown-utils/rundownUtils.test.ts | 333 ++-- .../utils/src/rundown-utils/rundownUtils.ts | 145 +- playwright.config.ts | 2 +- 111 files changed, 4380 insertions(+), 4632 deletions(-) delete mode 100644 apps/client/src/features/rundown/block-block/BlockDelete.tsx create mode 100644 apps/server/src/utils/__tests__/parser.mock-data.ts create mode 100644 e2e/tests/fixtures/e2e-test-db.json delete mode 100644 e2e/tests/fixtures/test-db.json diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 9edf73eb5b..0a3092d0d8 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { makeTable } from '../../views/cuesheet/cuesheet.utils'; -import { makeCSVFromArrayOfArrays } from '../utils/csv'; +import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv'; import { apiEntryUrl } from './constants'; import { createBlob, downloadBlob } from './utils'; @@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) { export async function downloadCSV(fileName: string = 'rundown') { try { const { data, name } = await fileDownload(fileName); - const { project, rundown, customFields } = data; + const { project, rundowns, customFields } = data; + + const flatRundowns = aggregateRundowns(rundowns); + const sheetData = makeTable(project, flatRundowns, customFields); - const sheetData = makeTable(project, rundown, customFields); const fileContent = makeCSVFromArrayOfArrays(sheetData); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts index b73aa585fd..5c21c987a8 100644 --- a/apps/client/src/common/api/excel.ts +++ b/apps/client/src/common/api/excel.ts @@ -1,16 +1,11 @@ import axios, { AxiosResponse } from 'axios'; -import { CustomFields, OntimeRundown } from 'ontime-types'; +import { CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; const excelPath = `${apiEntryUrl}/excel`; -type PreviewSpreadsheetResponse = { - rundown: OntimeRundown; - customFields: CustomFields; -}; - /** * upload Excel file to server * @return string - file ID op the uploaded file @@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise { return response.data; } +type PreviewSpreadsheetResponse = { + rundown: Rundown; + customFields: CustomFields; +}; export async function importRundownPreview(options: ImportMap): Promise { const response: AxiosResponse = await axios.post(`${excelPath}/preview`, { options, diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 9d854eee10..99fb044d4d 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,29 +1,44 @@ import axios, { AxiosResponse } from 'axios'; -import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; +import { + MessageResponse, + OntimeEntry, + OntimeEvent, + ProjectRundownsList, + Rundown, + TransientEventPayload, +} from 'ontime-types'; import { apiEntryUrl } from './constants'; const rundownPath = `${apiEntryUrl}/rundown`; +/** + * HTTP request to fetch a list of existing rundowns + */ +export async function fetchProjectRundownList(): Promise { + const res = await axios.get(`${rundownPath}/`); + return res.data; +} + /** * HTTP request to fetch all events */ -export async function fetchNormalisedRundown(): Promise { - const res = await axios.get(`${rundownPath}/normalised`); +export async function fetchCurrentRundown(): Promise { + const res = await axios.get(`${rundownPath}/current`); return res.data; } /** * HTTP request to post new event */ -export async function requestPostEvent(data: TransientEventPayload): Promise> { +export async function requestPostEvent(data: TransientEventPayload): Promise> { return axios.post(rundownPath, data); } /** * HTTP request to put new event */ -export async function requestPutEvent(data: Partial): Promise> { +export async function requestPutEvent(data: Partial): Promise> { return axios.put(rundownPath, data); } @@ -48,7 +63,7 @@ export type ReorderEntry = { /** * HTTP request to reorder events */ -export async function requestReorderEvent(data: ReorderEntry): Promise> { +export async function requestReorderEvent(data: ReorderEntry): Promise> { return axios.patch(`${rundownPath}/reorder`, data); } diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts index ebeb9fdce1..231f543db5 100644 --- a/apps/client/src/common/api/sheets.ts +++ b/apps/client/src/common/api/sheets.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; @@ -54,7 +54,7 @@ export const previewRundown = async ( sheetId: string, options: ImportMap, ): Promise<{ - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; }> => { const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 67629d4d94..9d360df0ad 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,23 +1,29 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { OntimeEntry, Rundown } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { RUNDOWN } from '../api/constants'; -import { fetchNormalisedRundown } from '../api/rundown'; +import { fetchCurrentRundown } from '../api/rundown'; import useProjectData from './useProjectData'; // revision is -1 so that the remote revision is higher -const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; +const cachedRundownPlaceholder: Rundown = { + id: 'default', + title: '', + order: [], + entries: {}, + revision: -1, +}; /** * Normalised rundown data */ export default function useRundown() { - const { data, status, isError, refetch, isFetching } = useQuery({ + const { data, status, isError, refetch, isFetching } = useQuery({ queryKey: RUNDOWN, - queryFn: fetchNormalisedRundown, + queryFn: fetchCurrentRundown, placeholderData: (previousData, _previousQuery) => previousData, retry: 5, retryDelay: (attempt) => attempt * 2500, @@ -37,16 +43,16 @@ export function useFlatRundown() { const loadedProject = useRef(''); const [prevRevision, setPrevRevision] = useState(-1); - const [flatRunDown, setFlatRunDown] = useState([]); + const [flatRundown, setFlatRundown] = useState([]); // update data whenever the revision changes useEffect(() => { if (data.revision !== -1 && data.revision !== prevRevision) { - const flatRundown = data.order.map((id) => data.rundown[id]); - setFlatRunDown(flatRundown); + const flatRundown = data.order.map((id) => data.entries[id]); + setFlatRundown(flatRundown); setPrevRevision(data.revision); } - }, [data.order, data.revision, data.rundown, prevRevision]); + }, [data.entries, data.order, data.revision, prevRevision]); // TODO: should we have a project id field? // invalidate current version if project changes @@ -57,13 +63,13 @@ export function useFlatRundown() { } }, [projectData]); - return { data: flatRunDown, status }; + return { data: flatRundown, status }; } /** * Provides access to a partial rundown based on a filter callback */ -export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) { +export function usePartialRundown(cb: (event: OntimeEntry) => boolean) { const { data, status } = useFlatRundown(); const filteredData = useMemo(() => { return data.filter(cb); diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 3ebe479e5d..e2e8b10649 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -2,11 +2,12 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { isOntimeEvent, + MaybeString, OntimeBlock, OntimeDelay, + OntimeEntry, OntimeEvent, - OntimeRundownEntry, - RundownCached, + Rundown, TimeField, TimeStrategy, TransientEventPayload, @@ -31,12 +32,12 @@ import { useEditorSettings } from '../stores/editorSettings'; export type EventOptions = Partial<{ // options to any new block (event / delay / block) - after: string; - before: string; + after: MaybeString; + before: MaybeString; // options to blocks of type OntimeEvent defaultPublic: boolean; linkPrevious: boolean; - lastEventId: string; + lastEventId: MaybeString; }>; /** @@ -57,11 +58,11 @@ export const useEventAction = () => { const getEventById = useCallback( (eventId: string) => { - const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.rundown) { + const cachedRundown = queryClient.getQueryData(RUNDOWN); + if (!cachedRundown?.entries) { return; } - return cachedRundown.rundown[eventId]; + return cachedRundown.entries[eventId]; }, [queryClient], ); @@ -100,9 +101,8 @@ export const useEventAction = () => { newEvent.linkStart = applicationOptions.lastEventId; } else if (applicationOptions?.lastEventId) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value - const rundownData = queryClient.getQueryData(RUNDOWN)!; - const { rundown } = rundownData; - const previousEvent = rundown[applicationOptions.lastEventId]; + const rundownData = queryClient.getQueryData(RUNDOWN)!; + const previousEvent = rundownData.entries[applicationOptions.lastEventId]; if (isOntimeEvent(previousEvent)) { newEvent.timeStart = previousEvent.timeEnd; } @@ -180,15 +180,21 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); const eventId = newEvent.id; if (previousData && eventId) { // optimistically update object - const newRundown = { ...previousData.rundown }; + const newRundown = { ...previousData.entries }; // @ts-expect-error -- we expect the events to be of same type newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; - queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: previousData.order, + entries: newRundown, + revision: -1, + }); } // Return a context with the previous and new events @@ -196,7 +202,7 @@ export const useEventAction = () => { }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -210,7 +216,7 @@ export const useEventAction = () => { * Updates existing event */ const updateEvent = useCallback( - async (event: Partial) => { + async (event: Partial) => { try { await _updateEventMutation.mutateAsync(event); } catch (error) { @@ -296,9 +302,9 @@ export const useEventAction = () => { * Utility function to get the previous event end time */ function getPreviousEnd(): number { - const cachedRundown = queryClient.getQueryData(RUNDOWN); + const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.order || !cachedRundown?.rundown) { + if (!cachedRundown?.order || !cachedRundown?.entries) { return 0; } @@ -308,7 +314,7 @@ export const useEventAction = () => { } let previousEnd = 0; for (let i = index - 1; i >= 0; i--) { - const event = cachedRundown.rundown[cachedRundown.order[i]]; + const event = cachedRundown.entries[cachedRundown.order[i]]; if (isOntimeEvent(event)) { previousEnd = event.timeEnd; break; @@ -331,11 +337,11 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousEvents = queryClient.getQueryData(RUNDOWN); + const previousRundown = queryClient.getQueryData(RUNDOWN); - if (previousEvents) { + if (previousRundown) { const eventIds = new Set(ids); - const newRundown = { ...previousEvents.rundown }; + const newRundown = { ...previousRundown.entries }; eventIds.forEach((eventId) => { if (Object.hasOwn(newRundown, eventId)) { @@ -349,16 +355,22 @@ export const useEventAction = () => { } }); - queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousRundown.id, + title: previousRundown.title, + order: previousRundown.order, + entries: newRundown, + revision: -1, + }); } - // Return a context with the previous and new events - return { previousEvents }; + // Return a context with the previous rundown + return { previousRundown }; }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousEvents); + queryClient.setQueryData(RUNDOWN, context?.previousRundown); }, networkMode: 'always', }); @@ -386,19 +398,21 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); - const newRundown = { ...previousData.rundown }; + const newRundown = { ...previousData.entries }; for (const eventId of eventIds) { delete newRundown[eventId]; } - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, order: newOrder, - rundown: newRundown, + entries: newRundown, revision: -1, }); } @@ -409,7 +423,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -445,10 +459,16 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); // optimistically update object - queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData?.id ?? 'default', + title: previousData?.title ?? '', + entries: {}, + order: [], + revision: -1, + }); // Return a context with the previous and new events return { previousData }; @@ -456,7 +476,7 @@ export const useEventAction = () => { // Mutation fails, rollback undos optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -516,13 +536,18 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const newOrder = reorderArray(previousData.order, data.from, data.to); - - queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: newOrder, + entries: previousData.entries, + revision: -1, + }); } // Return a context with the previous and new events @@ -531,7 +556,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -572,22 +597,28 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object - const newRundown = { ...previousData.rundown }; - const eventA = previousData.rundown[from]; - const eventB = previousData.rundown[to]; + const newRundown = { ...previousData.entries }; + const eventA = previousData.entries[from]; + const eventB = previousData.entries[to]; if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { return; } - const { newA, newB } = swapEventData(eventA, eventB); + const [newA, newB] = swapEventData(eventA, eventB); newRundown[from] = newA; newRundown[to] = newB; - queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: previousData.order, + entries: newRundown, + revision: -1, + }); } // Return a context with the previous events @@ -596,7 +627,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure diff --git a/apps/client/src/common/utils/__tests__/csv.test.ts b/apps/client/src/common/utils/__tests__/csv.test.ts index 13bd6b1b53..837b0a0cb9 100644 --- a/apps/client/src/common/utils/__tests__/csv.test.ts +++ b/apps/client/src/common/utils/__tests__/csv.test.ts @@ -1,4 +1,6 @@ -import { makeCSVFromArrayOfArrays } from '../csv'; +import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types'; + +import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv'; describe('makeCSVFromArrayOfArrays()', () => { it('joins an array of arrays with commas and newlines', () => { @@ -11,3 +13,32 @@ after newline,after comma `); }); }); + +describe('aggregateRundowns()', () => { + it('flattens an object of rundowns into a single array', () => { + const rundowns = { + first: { + id: '', + title: '', + revision: 0, + order: ['1', '2'], + entries: { + '1': { id: '1' } as OntimeEntry, + '2': { id: '2' } as OntimeEntry, + }, + }, + second: { + id: '', + title: '', + revision: 0, + order: ['3', '4'], + entries: { + '3': { id: '3' } as OntimeEntry, + '4': { id: '4' } as OntimeEntry, + }, + } as Rundown, + } as ProjectRundowns; + + expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]); + }); +}); diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index a2781d3e39..f54b5d7687 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -1,4 +1,4 @@ -import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; +import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; import { cloneEvent } from '../eventsManager'; @@ -29,7 +29,7 @@ describe('cloneEvent()', () => { gap: 0, custom: { lighting: '3', - } as EventCustomFields, + } as EntryCustomFields, }; const cloned = cloneEvent(original); diff --git a/apps/client/src/common/utils/__tests__/urlPresets.test.ts b/apps/client/src/common/utils/__tests__/urlPresets.test.ts index 7b127d7035..dbdcc8253d 100644 --- a/apps/client/src/common/utils/__tests__/urlPresets.test.ts +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.ts @@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => { describe('handle url sharing edge cases', () => { it('finds the correct preset when the url contains extra arguments', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() - }) + expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy(); + }); it('appends the feature params to the alias', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') - }) + expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); + }); }); }); @@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => { }); test('appends the feature params to the alias', () => { - expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); + expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe( + 'timer?user=guest&alias=demopage&locked=true&token=123', + ); }); }); describe('arePathsEquivalent()', () => { - it("checks whether the paths match", () => { + it('checks whether the paths match', () => { expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); - }) + }); - it("checks whether the params match", () => { + it('checks whether the params match', () => { expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); - }) + }); - it("considers edge cases for the url sharing feature", () => { + it('considers edge cases for the url sharing feature', () => { expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); - }) + }); }); - diff --git a/apps/client/src/common/utils/csv.ts b/apps/client/src/common/utils/csv.ts index 527e7a5f2c..055c0c9d64 100644 --- a/apps/client/src/common/utils/csv.ts +++ b/apps/client/src/common/utils/csv.ts @@ -1,10 +1,31 @@ import { stringify } from 'csv-stringify/browser/esm/sync'; +import { OntimeEntry, ProjectRundowns } from 'ontime-types'; /** - * @description Converts an array of arrays to a CSV file - * @param {string[][]} arrayOfArrays - * @return {string} + * Converts an array of arrays to a CSV file */ export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { return stringify(arrayOfArrays); } + +/** + * Receives an object of rundowns, and flattens them into a single, linear rundown + * Used for CSV export + */ +export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] { + const rundownKeys = Object.keys(rundowns); + if (rundownKeys.length === 0) return []; + const flatRundown: OntimeEntry[] = []; + + for (const key of rundownKeys) { + const { order, entries } = rundowns[key]; + + for (let i = 0; i < order.length; i++) { + const entryId = order[i]; + const entry = entries[entryId]; + + flatRundown.push(entry); + } + } + return flatRundown; +} diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index 51aeac4701..ad22b08201 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => { isPublic: event.isPublic, skip: event.skip, colour: event.colour, + currentBlock: event.currentBlock, revision: 0, delay: event.delay, // the events will be collocated, so having the same metadata is a good start dayOffset: event.dayOffset, diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index dd77cadf54..cde2d9c8a7 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,4 +1,4 @@ -import { Log, RundownCached, RuntimeStore } from 'ontime-types'; +import { Log, Rundown, RuntimeStore } from 'ontime-types'; import { isProduction, websocketUrl } from '../../externals'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; @@ -203,7 +203,7 @@ export const connectSocket = () => { invalidateAllCaches(); } else if (target === 'RUNDOWN') { const { revision } = payload; - const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; + const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; if (revision > currentRevision) { ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx index 090cc1d4a4..0d0926b1ff 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx @@ -29,8 +29,8 @@ export default function ReportSettings() { }; const combinedReport = useMemo(() => { - return getCombinedReport(reportData, data.rundown, data.order); - }, [reportData, data.rundown, data.order]); + return getCombinedReport(reportData, data.entries, data.order); + }, [reportData, data.entries, data.order]); return ( diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts index 0370baa001..d47ee4f9ae 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts @@ -1,4 +1,4 @@ -import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; +import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { formatTime } from '../../../../common/utils/time'; @@ -16,7 +16,7 @@ export type CombinedReport = { /** * Creates a combined report with the rundown data */ -export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { +export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] { if (Object.keys(report).length === 0) return []; if (order.length === 0) return []; diff --git a/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts index d3e6eddf96..48155a1054 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts +++ b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts @@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record void; onCancel: () => void; @@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) { const applyImport = async () => { setLoading(true); - await importRundown(rundown, customFields); + await importRundown( + { + [rundown.id]: rundown, + }, + customFields, + ); setLoading(false); onFinished(); }; diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx index 5944c446b0..c75800273f 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx @@ -1,6 +1,6 @@ import { Fragment } from 'react'; import { IoLink } from 'react-icons/io5'; -import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import Tag from '../../../../../common/components/tag/Tag'; @@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils'; import style from './PreviewRundown.module.scss'; interface PreviewRundownProps { - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; } @@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) { - {rundown.map((event) => { - if (isOntimeBlock(event)) { + {rundown.order.map((entryId) => { + const entry = rundown.entries[entryId]; + if (isOntimeBlock(entry)) { return ( - + - - {event.type} + {entry.type} - {event.title} + {entry.title} ); } - if (!isOntimeEvent(event)) { + if (!isOntimeEvent(entry)) { return null; } eventIndex += 1; - const colour = event.colour ? getAccessibleColour(event.colour) : {}; - const countToEnd = booleanToText(event.countToEnd); - const isPublic = booleanToText(event.isPublic); - const skip = booleanToText(event.skip); + const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; + const countToEnd = booleanToText(entry.countToEnd); + const isPublic = booleanToText(entry.isPublic); + const skip = booleanToText(entry.skip); return ( - + {eventIndex} - {event.type} + {entry.type} - {event.cue} - {event.title} + {entry.cue} + {entry.title} - {millisToString(event.timeStart)} - {event.linkStart && } + {millisToString(entry.timeStart)} + {entry.linkStart && } - {millisToString(event.timeEnd)} - {millisToString(event.duration)} - {millisToString(event.timeWarning)} - {millisToString(event.timeDanger)} + {millisToString(entry.timeEnd)} + {millisToString(entry.duration)} + {millisToString(entry.timeWarning)} + {millisToString(entry.timeDanger)} {countToEnd && {countToEnd}} {isPublic && {isPublic}} {skip && {skip}} - {event.colour} + {entry.colour} - {event.timerType} + {entry.timerType} - {event.endAction} + {entry.endAction} - {isOntimeEvent(event) && + {isOntimeEvent(entry) && fieldKeys.map((field) => { let value = ''; - if (field in event.custom) { - value = event.custom[field]; + if (field in entry.custom) { + value = entry.custom[field]; } return {value}; })} - {event.id} + {entry.id} - {event.note && ( + {entry.note && ( - Note: {event.note} + Note: {entry.note} )} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts index 29198eac0d..3cc93c3620 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query'; -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; @@ -75,9 +75,9 @@ export default function useGoogleSheet() { }; /** applies rundown and customFields to current project */ - const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { + const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => { try { - await patchData({ rundown, customFields }); + await patchData({ rundowns, customFields }); // we are unable to optimistically set the rundown since we need // it to be normalised await queryClient.invalidateQueries({ diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts index efed9ce863..c2e49ae54c 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts @@ -1,4 +1,4 @@ -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { defaultImportMap, ImportMap } from 'ontime-utils'; import { create } from 'zustand'; @@ -15,8 +15,8 @@ type SheetStore = { setAuthenticationStatus: (status: AuthenticationStatus) => void; // we get this from a preview response - rundown: OntimeRundown | null; - setRundown: (rundown: OntimeRundown | null) => void; + rundown: Rundown | null; + setRundown: (rundown: Rundown | null) => void; // we get this from a preview response customFields: CustomFields | null; @@ -60,7 +60,7 @@ export const useSheetStore = create((set, get) => ({ setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), - setRundown: (rundown: OntimeRundown | null) => set({ rundown }), + setRundown: (rundown: Rundown | null) => set({ rundown }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }), diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 6a1c4613b8..e7e8492085 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -126,8 +126,8 @@ export default function Operator() { let isPast = Boolean(featureData.selectedEventId); const hidePast = isStringBoolean(searchParams.get('hidepast')); - const { firstEvent } = getFirstEventNormal(data.rundown, data.order); - const { lastEvent } = getLastEventNormal(data.rundown, data.order); + const { firstEvent } = getFirstEventNormal(data.entries, data.order); + const { lastEvent } = getLastEventNormal(data.entries, data.order); return (
@@ -152,7 +152,7 @@ export default function Operator() {
{data.order.map((eventId) => { - const entry = data.rundown[eventId]; + const entry = data.entries[eventId]; if (isOntimeEvent(entry)) { const isSelected = featureData.selectedEventId === entry.id; if (isSelected) { diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 0d3cfc49f4..392f9c4aee 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -1,15 +1,16 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useHotkeys } from '@mantine/hooks'; import { + type EntryId, + type MaybeString, + type PlayableEvent, + type Rundown, isOntimeBlock, isOntimeEvent, isPlayableEvent, - MaybeString, - PlayableEvent, Playback, - RundownCached, SupportedEvent, } from 'ontime-types'; import { @@ -21,6 +22,7 @@ import { getPreviousBlockNormal, getPreviousNormal, isNewLatest, + reorderArray, } from 'ontime-utils'; import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; @@ -39,12 +41,12 @@ import style from './Rundown.module.scss'; const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { - data: RundownCached; + data: Rundown; } export default function Rundown({ data }: RundownProps) { - const { order, rundown } = data; - const [statefulEntries, setStatefulEntries] = useState(order); + const { order, entries } = data; + const [statefulEntries, setStatefulEntries] = useState(order); const featureData = useRundownEditor(); const { addEvent, reorderEvent, deleteEvent } = useEventAction(); @@ -65,30 +67,30 @@ export default function Rundown({ data }: RundownProps) { const deleteAtCursor = useCallback( (cursor: string | null) => { if (!cursor) return; - const { entry, index } = getPreviousNormal(rundown, order, cursor); + const { entry, index } = getPreviousNormal(entries, order, cursor); deleteEvent([cursor]); if (entry && index !== null) { setSelectedEvents({ id: entry.id, selectMode: 'click', index }); } }, - [rundown, order, deleteEvent, setSelectedEvents], + [entries, order, deleteEvent, setSelectedEvents], ); const insertCopyAtId = useCallback( (atId: string | null, copyId: string | null, above = false) => { - const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; + const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId; if (copyId === null) { // we cant clone without selection return; } - const cloneEntry = rundown[copyId]; + const cloneEntry = entries[copyId]; if (cloneEntry?.type === SupportedEvent.Event) { //if we don't have a cursor add the new event on top const newEvent = cloneEvent(cloneEntry); addEvent(newEvent, { after: adjustedCursor ?? undefined }); } }, - [addEvent, order, rundown], + [addEvent, order, entries], ); const insertAtId = useCallback( @@ -124,7 +126,7 @@ export default function Rundown({ data }: RundownProps) { let newCursor = cursor; if (cursor === null) { // there is no cursor, we select the first or last depending on direction - const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); if (isOntimeBlock(selected)) { setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); @@ -140,14 +142,14 @@ export default function Rundown({ data }: RundownProps) { // otherwise we select the next or previous const selected = direction === 'up' - ? getPreviousBlockNormal(rundown, order, newCursor) - : getNextBlockNormal(rundown, order, newCursor); + ? getPreviousBlockNormal(entries, order, newCursor) + : getNextBlockNormal(entries, order, newCursor); if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); } }, - [order, rundown, setSelectedEvents], + [order, entries, setSelectedEvents], ); const selectEntry = useCallback( @@ -158,7 +160,7 @@ export default function Rundown({ data }: RundownProps) { if (cursor === null) { // there is no cursor, we select the first or last depending on direction if it exists - const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); if (selected !== null) { setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); } @@ -167,13 +169,13 @@ export default function Rundown({ data }: RundownProps) { // otherwise we select the next or previous const selected = - direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); } }, - [order, rundown, setSelectedEvents], + [order, entries, setSelectedEvents], ); const moveEntry = useCallback( @@ -182,14 +184,14 @@ export default function Rundown({ data }: RundownProps) { return; } const { index } = - direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); if (index !== null) { const offsetIndex = direction === 'up' ? index + 1 : index - 1; reorderEvent(cursor, offsetIndex, index); } }, - [order, reorderEvent, rundown], + [order, reorderEvent, entries], ); // shortcuts @@ -238,6 +240,9 @@ export default function Rundown({ data }: RundownProps) { setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]); + /** + * On drag end, we reorder the events + */ const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -245,9 +250,10 @@ export default function Rundown({ data }: RundownProps) { if (active.id !== over?.id) { const fromIndex = active.data.current?.sortable.index; const toIndex = over.data.current?.sortable.index; - // ugly hack to handle inconsistencies between dnd-kit and async store updates + + // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates setStatefulEntries((currentEntries) => { - return arrayMove(currentEntries, fromIndex, toIndex); + return reorderArray(currentEntries, fromIndex, toIndex); }); reorderEvent(String(active.id), fromIndex, toIndex); } @@ -259,11 +265,11 @@ export default function Rundown({ data }: RundownProps) { } // last event is used to calculate relative timings - let lastEvent: PlayableEvent | undefined; // used by indicators - let thisEvent: PlayableEvent | undefined; + let lastEvent: PlayableEvent | null = null; // used by indicators + let thisEvent: PlayableEvent | null = null; // previous entry is used to infer position in the rundown for new events - let previousEntryId: string | undefined; - let thisId = previousEntryId; + let previousEntryId: MaybeString = null; + let thisId: MaybeString = null; let eventIndex = 0; // all events before the current selected are in the past @@ -272,6 +278,7 @@ export default function Rundown({ data }: RundownProps) { let totalGap = 0; const isEditMode = appMode === AppMode.Edit; let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event + return (
@@ -281,7 +288,7 @@ export default function Rundown({ data }: RundownProps) { // we iterate through a stateful copy of order to make the operations smoother // this means that this can be out of sync with order until the useEffect runs // instead of writing all the logic guards, we simply short circuit rendering here - const entry = rundown[entryId]; + const entry = entries[entryId]; if (!entry) { return null; } diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 6deb859dde..70153796a2 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,5 +1,14 @@ import { useCallback } from 'react'; -import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; +import { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + MaybeString, + OntimeEntry, + OntimeEvent, + Playback, + SupportedEvent, +} from 'ontime-types'; import { useEventAction } from '../../common/hooks/useEventAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; @@ -28,13 +37,13 @@ export type EventItemActions = interface RundownEntryProps { type: SupportedEvent; isPast: boolean; - data: OntimeRundownEntry; + data: OntimeEntry; loaded: boolean; eventIndex: number; hasCursor: boolean; isNext: boolean; isNextDay: boolean; - previousEntryId?: string; + previousEntryId: MaybeString; previousEventId?: string; playback?: Playback; // we only care about this if this event is playing isRolling: boolean; // we need to know even if not related to this event @@ -150,7 +159,7 @@ export default function RundownEntry(props: RundownEntryProps) { } }); - if (data.type === SupportedEvent.Event) { + if (isOntimeEvent(data)) { return ( ); - } else if (data.type === SupportedEvent.Block) { - return actionHandler('delete')} />; - } else if (data.type === SupportedEvent.Delay) { + } else if (isOntimeBlock(data)) { + return ( + + {data.events.map((eventId) => { + return
{eventId}
; + })} +
+ ); + } else if (isOntimeDelay(data)) { return ; } return null; 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..b5d6345a6b 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -22,7 +22,3 @@ .drag { @include drag-style; } - -.actionMenu { - justify-self: flex-end; -} diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index a9e18753a1..5f3d0abad8 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { PropsWithChildren, useRef } from 'react'; import { IoReorderTwo } from 'react-icons/io5'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -7,18 +7,15 @@ import { OntimeBlock } from 'ontime-types'; import { cx } from '../../../common/utils/styleUtils'; import EditableBlockTitle from '../common/EditableBlockTitle'; -import BlockDelete from './BlockDelete'; - import style from './BlockBlock.module.scss'; interface BlockBlockProps { data: OntimeBlock; hasCursor: boolean; - onDelete: () => void; } -export default function BlockBlock(props: BlockBlockProps) { - const { data, hasCursor, onDelete } = props; +export default function BlockBlock(props: PropsWithChildren) { + const { data, hasCursor, children } = props; const handleRef = useRef(null); @@ -46,7 +43,8 @@ export default function BlockBlock(props: BlockBlockProps) { - + +
{children}
); } 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/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/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx index f21f5a0ed0..2def764692 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 = data.order.find((entryId) => selectedEvents.has(entryId)); 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/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index 1062a2d8d5..66eb7a5969 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useRef } from 'react'; import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; -import { SupportedEvent } from 'ontime-types'; +import { MaybeString, SupportedEvent } from 'ontime-types'; import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEmitLog } from '../../../common/stores/logger'; @@ -9,7 +9,7 @@ import { useEmitLog } from '../../../common/stores/logger'; import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { - previousEventId?: string; + previousEventId: MaybeString; } export default memo(QuickAddBlock); diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index d286abb719..c464756ce5 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,5 +1,5 @@ import { MouseEvent } from 'react'; -import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types'; +import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types'; import { create } from 'zustand'; import { RUNDOWN } from '../../common/api/constants'; @@ -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,13 +62,13 @@ 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 event = rundownData.entries[eventId]; if (isOntimeEvent(event)) { events.push(event); } diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index e74bce4999..3cf69c2e8c 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { + OntimeEntry, OntimeEvent, - OntimeRundownEntry, Playback, ProjectData, Runtime, @@ -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..8cfcf78fbb 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, SupportedEvent } 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 === SupportedEvent.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/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..a3d3c59d60 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,7 +1,7 @@ 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 useFollowComponent from '../../../common/hooks/useFollowComponent'; @@ -16,8 +16,8 @@ import useColumnManager from './useColumnManager'; import style from './CuesheetTable.module.scss'; interface CuesheetTableProps { - data: OntimeRundown; - columns: ColumnDef[]; + data: OntimeEntry[]; + columns: ColumnDef[]; showModal: (eventId: MaybeString) => void; } 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..9fb86773d2 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; } @@ -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-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.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index fd43ffd2e6..8c10681236 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -1,6 +1,6 @@ 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, @@ -87,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(() => { @@ -102,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]; @@ -164,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/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..5bb18118d2 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, 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..4d1436fe41 100644 --- a/apps/server/src/api-data/excel/excel.controller.ts +++ b/apps/server/src/api-data/excel/excel.controller.ts @@ -5,6 +5,7 @@ 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 { @@ -29,7 +30,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.service.ts b/apps/server/src/api-data/excel/excel.service.ts index 70d32e280c..383b5c129a 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,7 +12,7 @@ import xlsx from 'xlsx'; import type { WorkBook } from 'xlsx'; import { parseExcel } from '../../utils/parser.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; +import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { getCustomFields } from '../../services/rundown-service/rundownCache.js'; @@ -34,7 +34,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 +43,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/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index f40ab53b6b..dde0501ddd 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -1,4 +1,4 @@ -import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; @@ -14,19 +14,19 @@ import { reorderEvent, swapEvents, } from '../../services/rundown-service/RundownService.js'; -import { getEventWithId, getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js'; +import { getEventWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; -export async function rundownGetAll(_req: Request, res: Response) { - const rundown = getRundown(); - res.json(rundown); +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 { @@ -43,7 +43,7 @@ export async function rundownGetById(req: Request, res: Response) { +export async function rundownPost(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } @@ -57,7 +57,7 @@ export async function rundownPost(req: Request, res: Response) { +export async function rundownPut(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } @@ -86,7 +86,7 @@ export async function rundownBatchPut(req: Request, res: Response) { +export async function rundownReorder(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 38b6cd15a7..68f55e1d3c 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -7,7 +7,7 @@ import { rundownDelete, rundownGetAll, rundownGetById, - rundownGetNormalised, + rundownGetCurrent, rundownPost, rundownPut, rundownReorder, @@ -25,8 +25,8 @@ import { export const router = express.Router(); -router.get('/', rundownGetAll); // not used in Ontime frontend -router.get('/normalised', rundownGetNormalised); +router.get('/', rundownGetAll); +router.get('/current', rundownGetCurrent); router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend router.post('/', rundownPostValidator, rundownPost); diff --git a/apps/server/src/api-data/sheets/sheets.controller.ts b/apps/server/src/api-data/sheets/sheets.controller.ts index 026e5384a5..b82935b51b 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 { @@ -87,7 +87,7 @@ export async function readFromSheet( req: Request, res: Response< | { - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; } | ErrorResponse diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index d9a79ecdce..8ddb456959 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -1,12 +1,13 @@ import { ProjectData, - OntimeRundown, ViewSettings, DatabaseModel, Settings, CustomFields, URLPreset, AutomationSettings, + Rundown, + ProjectRundowns, } from 'ontime-types'; import type { Low } from 'lowdb'; @@ -45,6 +46,7 @@ export function getDataProvider() { setCustomFields, getCustomFields, setRundown, + mergeRundown, getSettings, setSettings, getUrlPresets, @@ -78,14 +80,28 @@ async function setCustomFields(newData: CustomFields): ReadonlyPromise { + 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(): Readonly { return db.data.customFields; } -async function setRundown(newData: OntimeRundown): ReadonlyPromise { - db.data.rundown = newData; +async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { + db.data.rundowns[rundownKey] = newData; await persist(); - return db.data.rundown; + return db.data.rundowns[rundownKey]; } function getSettings(): Readonly { @@ -128,8 +144,9 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise { - return db.data.rundown; +function getRundown(): Readonly { + const firstRundown = Object.keys(db.data.rundowns)[0]; + return db.data.rundowns[firstRundown]; } async function mergeIntoData(newData: Partial): ReadonlyPromise { @@ -140,7 +157,7 @@ async function mergeIntoData(newData: Partial): ReadonlyPromise): DatabaseModel { const { - rundown = existing.rundown, + rundowns = {}, project = {}, settings = {}, viewSettings = {}, @@ -16,7 +16,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial { - const existing = { - rundown: [], - project: { - title: 'existing title', - description: 'existing description', - publicUrl: 'existing public URL', - backstageUrl: 'existing backstageUrl', - publicInfo: 'existing backstageInfo', - backstageInfo: 'existing backstageInfo', - projectLogo: null, - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - editorKey: null, - operatorKey: null, - timeFormat: '24', - language: 'en', - }, - viewSettings: { - overrideStyles: false, - freezeEnd: false, - endMessage: 'existing endMessage', - normalColor: '#ffffffcc', - warningColor: '#FFAB33', - dangerColor: '#ED3333', - }, - urlPresets: [], - customFields: { - lighting: { type: 'string', label: 'lighting', colour: 'red' }, - vfx: { type: 'string', label: 'vfx', colour: 'blue' }, - }, - automation: { - enabledAutomations: false, - enabledOscIn: false, - oscPortIn: 8000, - triggers: [], - automations: {}, - }, - } as DatabaseModel; - it('returns existing data if new data is not provided', () => { - const mergedData = safeMerge(existing, {}); - expect(mergedData).toEqual(existing); + const mergedData = safeMerge(demoDb, {}); + expect(mergedData).toEqual(demoDb); }); - it('merges the rundown key', () => { - const newData = { - rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown, - }; - const mergedData = safeMerge(existing, newData); - expect(mergedData.rundown).toEqual(newData.rundown); + it('overrides a rundown with the same key', () => { + const newData = makeRundown({ + id: 'demo', + entries: { + '1': makeOntimeEvent({ id: '1', title: 'new title' }), + '2': makeOntimeEvent({ id: '1', title: 'new title' }), + }, + order: ['1', '2'], + }); + const mergedData = safeMerge(demoDb, { rundowns: { demo: newData } }); + expect(mergedData.rundowns.demo).toStrictEqual(newData); + }); + + it('merges a rundown with a new key', () => { + const newData = makeRundown({ + id: 'rundown', + entries: { + '1': makeOntimeEvent({ id: '1', title: 'new title' }), + '2': makeOntimeEvent({ id: '1', title: 'new title' }), + }, + order: ['1', '2'], + }); + const mergedData = safeMerge(demoDb, { rundowns: { rundown: newData } }); + expect(mergedData.rundowns.demo).toStrictEqual(demoDb.rundowns.demo); + expect(mergedData.rundowns.rundown).toStrictEqual(newData); }); it('merges the project key', () => { - const newData = { + const mergedData = safeMerge(demoDb, { project: { title: 'new title', publicInfo: 'new public info', + backstageInfo: 'new backstage info', }, - }; - // @ts-expect-error -- just testing - const mergedData = safeMerge(existing, newData); - expect(mergedData.project).toEqual({ + } as Partial); + + expect(mergedData.project).toStrictEqual({ title: 'new title', - description: 'existing description', - publicUrl: 'existing public URL', + description: 'Turin 2022', + publicUrl: 'www.getontime.no', publicInfo: 'new public info', - backstageUrl: 'existing backstageUrl', - backstageInfo: 'existing backstageInfo', + backstageUrl: 'www.github.com/cpvalente/ontime', + backstageInfo: 'new backstage info', projectLogo: null, }); }); it('merges the settings key', () => { - const newData = { + const mergedData = safeMerge(demoDb, { settings: { serverPort: 3000, language: 'pt', + version: 'new', } as Settings, - }; - const mergedData = safeMerge(existing, newData); - expect(mergedData.settings).toEqual({ + }); + expect(mergedData.settings).toStrictEqual({ app: 'ontime', - version: '2.0.0', + version: 'new', serverPort: 3000, operatorKey: null, editorKey: null, @@ -97,41 +78,6 @@ describe('safeMerge', () => { }); it('should merge the urlPresets key when present', () => { - const existingData = { - rundown: [], - project: { - title: '', - description: '', - publicUrl: '', - publicInfo: '', - backstageUrl: '', - backstageInfo: '', - projectLogo: null, - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - operatorKey: null, - editorKey: null, - timeFormat: '24', - language: 'en', - }, - viewSettings: { - overrideStyles: false, - endMessage: '', - } as ViewSettings, - urlPresets: [], - customFields: {}, - automation: { - enabledAutomations: false, - enabledOscIn: false, - oscPortIn: 8000, - triggers: [], - automations: {}, - }, - } as DatabaseModel; - const newData = { urlPresets: [ { enabled: true, alias: 'alias1', pathAndParams: '' }, @@ -139,9 +85,9 @@ describe('safeMerge', () => { ] as URLPreset[], }; - const mergedData = safeMerge(existingData, newData); + const mergedData = safeMerge(demoDb, newData); - expect(mergedData.urlPresets).toEqual(newData.urlPresets); + expect(mergedData.urlPresets).toStrictEqual(newData.urlPresets); }); it('merges customFields into existing object', () => { diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index a4a73a437f..8b16c8b04b 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -1,8 +1,18 @@ -import { DatabaseModel } from 'ontime-types'; +import { DatabaseModel, Rundown } from 'ontime-types'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; +export const defaultRundown: Rundown = { + id: 'default', + title: 'Default', + order: [], + entries: {}, + revision: 0, +}; + export const dbModel: DatabaseModel = { - rundown: [], + rundowns: { + default: { ...defaultRundown }, + }, project: { title: '', description: '', diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index b57e35dd61..94aa96c7be 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -1,410 +1,475 @@ import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types'; export const demoDb: DatabaseModel = { - rundown: [ - { - type: SupportedEvent.Event, - id: '32d31', - cue: 'SF1.01', - title: 'Albania', - note: 'SF1.01', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 36000000, - timeEnd: 37200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Sekret', - artist: 'Ronela Hajati', - }, - }, - { - type: SupportedEvent.Event, - id: '21cd2', - cue: 'SF1.02', - title: 'Latvia', - note: 'SF1.02', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 37500000, - timeEnd: 38700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Eat Your Salad', - artist: 'Citi Zeni', - }, - }, - { - type: SupportedEvent.Event, - id: '0b371', - cue: 'SF1.03', - title: 'Lithuania', - note: 'SF1.03', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 39000000, - timeEnd: 40200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Sentimentai', - artist: 'Monika Liu', - }, - }, - { - type: SupportedEvent.Event, - id: '3cd28', - cue: 'SF1.04', - title: 'Switzerland', - note: 'SF1.04', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 40500000, - timeEnd: 41700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Boys Do Cry', - artist: 'Marius Bear', - }, - }, - { - type: SupportedEvent.Event, - id: 'e457f', - cue: 'SF1.05', - title: 'Slovenia', - note: 'SF1.05', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 42000000, - timeEnd: 43200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Disko', - artist: 'LPS', + rundowns: { + demo: { + id: 'demo', + title: 'Eurovision Demo', + order: [ + '32d31', + '21cd2', + '0b371', + '3cd28', + 'e457f', + '01e85', + '1c420', + 'b7737', + 'd3a80', + '8276c', + '2340b', + 'cb90b', + '503c4', + '5e965', + 'bab4a', + 'd3eb1', + ], + entries: { + '32d31': { + type: SupportedEvent.Event, + id: '32d31', + cue: 'SF1.01', + title: 'Albania', + note: 'SF1.01', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 36000000, + timeEnd: 37200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Sekret', + artist: 'Ronela Hajati', + }, + }, + '21cd2': { + type: SupportedEvent.Event, + id: '21cd2', + cue: 'SF1.02', + title: 'Latvia', + note: 'SF1.02', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 37500000, + timeEnd: 38700000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Eat Your Salad', + artist: 'Citi Zeni', + }, + }, + '0b371': { + type: SupportedEvent.Event, + id: '0b371', + cue: 'SF1.03', + title: 'Lithuania', + note: 'SF1.03', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 39000000, + timeEnd: 40200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Sentimentai', + artist: 'Monika Liu', + }, + }, + '3cd28': { + type: SupportedEvent.Event, + id: '3cd28', + cue: 'SF1.04', + title: 'Switzerland', + note: 'SF1.04', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 40500000, + timeEnd: 41700000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Boys Do Cry', + artist: 'Marius Bear', + }, + }, + e457f: { + type: SupportedEvent.Event, + id: 'e457f', + cue: 'SF1.05', + title: 'Slovenia', + note: 'SF1.05', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 42000000, + timeEnd: 43200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Disko', + artist: 'LPS', + }, + }, + /// <----- BLOCK + '01e85': { + // TODO: this should be a marker type + type: SupportedEvent.Block, + id: '01e85', + title: 'Lunch break', + note: '', + colour: '', + events: [], + skip: false, + custom: {}, + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: 0, + }, + '1c420': { + type: SupportedEvent.Event, + id: '1c420', + cue: 'SF1.06', + title: 'Ukraine', + note: 'SF1.06', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 47100000, + timeEnd: 48300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Stefania', + artist: 'Kalush Orchestra', + }, + }, + b7737: { + type: SupportedEvent.Event, + id: 'b7737', + cue: 'SF1.07', + title: 'Bulgaria', + note: 'SF1.07', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 48600000, + timeEnd: 49800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Intention', + artist: 'Intelligent Music Project', + }, + }, + d3a80: { + type: SupportedEvent.Event, + id: 'd3a80', + cue: 'SF1.08', + title: 'Netherlands', + note: 'SF1.08', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 50100000, + timeEnd: 51300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'De Diepte', + artist: 'S10', + }, + }, + '8276c': { + type: SupportedEvent.Event, + id: '8276c', + cue: 'SF1.09', + title: 'Moldova', + note: 'SF1.09', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 51600000, + timeEnd: 52800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Trenuletul', + artist: 'Zdob si Zdub', + }, + }, + '2340b': { + type: SupportedEvent.Event, + id: '2340b', + cue: 'SF1.10', + title: 'Portugal', + note: 'SF1.10', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 53100000, + timeEnd: 54300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Saudade Saudade', + artist: 'Maro', + }, + }, + /// <----- BLOCK + cb90b: { + // TODO: This should be a marker type + type: SupportedEvent.Block, + id: 'cb90b', + title: 'Afternoon break', + note: '', + colour: '', + events: [], + skip: false, + custom: {}, + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: 0, + }, + '503c4': { + type: SupportedEvent.Event, + id: '503c4', + cue: 'SF1.11', + title: 'Croatia', + note: 'SF1.11', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 56100000, + timeEnd: 57300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Guilty Pleasure', + artist: 'Mia Dimsic', + }, + }, + '5e965': { + type: SupportedEvent.Event, + id: '5e965', + cue: 'SF1.12', + title: 'Denmark', + note: 'SF1.12', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 57600000, + timeEnd: 58800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'The Show', + artist: 'Reddi', + }, + }, + bab4a: { + type: SupportedEvent.Event, + id: 'bab4a', + cue: 'SF1.13', + title: 'Austria', + note: 'SF1.13', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 59100000, + timeEnd: 60300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Halo', + artist: 'LUM!X & Pia Maria', + }, + }, + d3eb1: { + type: SupportedEvent.Event, + id: 'd3eb1', + cue: 'SF1.14', + title: 'Greece', + note: 'SF1.14', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 60600000, + timeEnd: 61800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Die Together', + artist: 'Amanda Tenfjord', + }, + }, }, - }, - { - type: SupportedEvent.Block, - id: '01e85', - title: 'Lunch break', - }, - { - type: SupportedEvent.Event, - id: '1c420', - cue: 'SF1.06', - title: 'Ukraine', - note: 'SF1.06', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 47100000, - timeEnd: 48300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Stefania', - artist: 'Kalush Orchestra', - }, }, - { - type: SupportedEvent.Event, - id: 'b7737', - cue: 'SF1.07', - title: 'Bulgaria', - note: 'SF1.07', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 48600000, - timeEnd: 49800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Intention', - artist: 'Intelligent Music Project', - }, - }, - { - type: SupportedEvent.Event, - id: 'd3a80', - cue: 'SF1.08', - title: 'Netherlands', - note: 'SF1.08', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 50100000, - timeEnd: 51300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'De Diepte', - artist: 'S10', - }, - }, - { - type: SupportedEvent.Event, - id: '8276c', - cue: 'SF1.09', - title: 'Moldova', - note: 'SF1.09', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 51600000, - timeEnd: 52800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Trenuletul', - artist: 'Zdob si Zdub', - }, - }, - { - type: SupportedEvent.Event, - id: '2340b', - cue: 'SF1.10', - title: 'Portugal', - note: 'SF1.10', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 53100000, - timeEnd: 54300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Saudade Saudade', - artist: 'Maro', - }, - }, - { - type: SupportedEvent.Block, - id: 'cb90b', - title: 'Afternoon break', - }, - { - type: SupportedEvent.Event, - id: '503c4', - cue: 'SF1.11', - title: 'Croatia', - note: 'SF1.11', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 56100000, - timeEnd: 57300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Guilty Pleasure', - artist: 'Mia Dimsic', - }, - }, - { - type: SupportedEvent.Event, - id: '5e965', - cue: 'SF1.12', - title: 'Denmark', - note: 'SF1.12', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 57600000, - timeEnd: 58800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'The Show', - artist: 'Reddi', - }, - }, - { - type: SupportedEvent.Event, - id: 'bab4a', - cue: 'SF1.13', - title: 'Austria', - note: 'SF1.13', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 59100000, - timeEnd: 60300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Halo', - artist: 'LUM!X & Pia Maria', - }, - }, - { - type: SupportedEvent.Event, - id: 'd3eb1', - cue: 'SF1.14', - title: 'Greece', - note: 'SF1.14', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 60600000, - timeEnd: 61800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Die Together', - artist: 'Amanda Tenfjord', - }, - }, - ], + }, project: { title: 'Eurovision Song Contest', description: 'Turin 2022', @@ -416,7 +481,7 @@ export const demoDb: DatabaseModel = { }, settings: { app: 'ontime', - version: '3.3.2', + version: '-', serverPort: 4001, editorKey: null, operatorKey: null, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index d8b0846c5d..725aedac28 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -9,6 +9,7 @@ import { } from 'ontime-types'; export const event: Omit = { + type: SupportedEvent.Event, title: '', note: '', endAction: EndAction.None, @@ -22,22 +23,33 @@ export const event: Omit = { isPublic: false, skip: false, colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, + currentBlock: null, + revision: 0, // calculated at runtime + delay: 0, // calculated at runtime + dayOffset: 0, // calculated at runtime + gap: 0, // calculated at runtime timeWarning: 120000, timeDanger: 60000, custom: {}, }; export const delay: Omit = { - duration: 0, type: SupportedEvent.Delay, + duration: 0, }; export const block: Omit = { - title: '', type: SupportedEvent.Block, + title: '', + note: '', + events: [], + skip: false, + colour: '', + revision: 0, // calculated at runtime + startTime: null, // calculated at runtime + endTime: null, // calculated at runtime + duration: 0, // calculated at runtime + isFirstLinked: false, // calculated at runtime + numEvents: 0, // calculated at runtime + custom: {}, }; diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index c56fcfe725..252e728f91 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -18,7 +18,7 @@ import { import { dbModel } from '../../models/dataModel.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { parseDatabaseModel } from '../../utils/parser.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; +import { parseRundowns } from '../../utils/parserFunctions.js'; import { demoDb } from '../../models/demoProject.js'; import { config } from '../../setup/config.js'; import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; @@ -40,6 +40,7 @@ import { moveCorruptFile, parseJsonFile, } from './projectServiceUtils.js'; +import { getFirstRundown } from '../rundown-service/rundownUtils.js'; // init dependencies init(); @@ -83,7 +84,7 @@ async function loadNewProject(): Promise { } /** - * Private function handles side effects on currupted files + * Private function handles side effects on corrupted files * Corrupted files in this context contain data that failed domain validation */ async function handleCorruptedFile(filePath: string, fileName: string): Promise { @@ -176,10 +177,11 @@ export async function loadProjectFile(name: string) { // apply data model runtimeService.stop(); - const { rundown, customFields } = result.data; + const { rundowns, customFields } = result.data; // apply the rundown - await initRundown(rundown, customFields); + const firstRundown = getFirstRundown(rundowns); + await initRundown(firstRundown, customFields); } /** @@ -246,10 +248,11 @@ export async function renameProjectFile(originalFile: string, newFilename: strin // apply data model runtimeService.stop(); - const { rundown, customFields } = result.data; + const { rundowns, customFields } = result.data; // apply the rundown - await initRundown(rundown, customFields); + const firstRundown = getFirstRundown(rundowns); + await initRundown(firstRundown, customFields); } } @@ -300,17 +303,23 @@ export async function patchCurrentProject(data: Partial) { runtimeService.stop(); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging - const { rundown, customFields, ...rest } = data; + const { rundowns, customFields, ...rest } = data; // we can pass some stuff straight to the data provider - const newData = await getDataProvider().mergeIntoData(rest); + await getDataProvider().mergeIntoData(rest); // ... but rundown and custom fields need to be checked - if (rundown != null) { - const result = parseRundown(data); - await initRundown(result.rundown, result.customFields); + if (rundowns != null) { + const result = parseRundowns(data); + /** + * The user may have multiple rundowns + * We currently ignore all other rundowns + */ + const firstRundown = getFirstRundown(result.rundowns); + initRundown(firstRundown, result.customFields); } - return newData; + const updatedData = await getDataProvider().getData(); + return updatedData; } /** diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index b716e5b6da..6b08b721bc 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -44,12 +44,12 @@ describe('duplicateProjectFile', () => { await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found'); }); - it('throws an error if new file name is already a project', () => { + it('throws an error if new file name is already a project', async () => { // current project exists (doesProjectExist as Mock).mockReturnValueOnce('thisoneexists'); // new project exists (doesProjectExist as Mock).mockReturnValueOnce('existingproject'); - expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow( + await expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow( 'Project file with name existingproject already exists', ); }); @@ -66,7 +66,7 @@ describe('renameProjectFile', () => { (doesProjectExist as Mock).mockReturnValueOnce('this one exists'); // new project exists (doesProjectExist as Mock).mockReturnValueOnce('existingproject'); - expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow( + await expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow( 'Project file with name existingproject already exists', ); }); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index f8efff352d..e32e5c7a24 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -4,13 +4,14 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, - OntimeRundownEntry, + OntimeEntry, isOntimeBlock, isOntimeDelay, isOntimeEvent, - OntimeRundown, PatchWithId, EventPostPayload, + Rundown, + EntryId, } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; @@ -22,7 +23,6 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getPlayableEvents, getTimedEvents } from './rundownUtils.js'; type CompleteEntry = T extends Partial @@ -33,15 +33,23 @@ type CompleteEntry = ? OntimeBlock : never; +/** + * Generates a fully formed RundownEntry of the patch type + */ function generateEvent | Partial | Partial>( eventData: T, afterId?: string, ): CompleteEntry { + // TODO: could we keep the UI ID to avoid the flash on create? // we discard any UI provided IDs and add our own const id = cache.getUniqueId(); if (isOntimeEvent(eventData)) { - return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry; + const currentRundown = cache.getCurrentRundown(); + return createEvent( + eventData, + getCueCandidate(currentRundown.entries, currentRundown.order, afterId), + ) as CompleteEntry; } if (isOntimeDelay(eventData)) { @@ -56,19 +64,17 @@ function generateEvent | Partial | P } /** - * @description creates a new event with given data - * @param {object} eventData - * @return {OntimeRundownEntry} + * creates a new event with given data */ -export async function addEvent(eventData: EventPostPayload): Promise { +export async function addEvent(eventData: EventPostPayload): Promise { // if the user didnt provide an index, we add the event to start let atIndex = 0; let afterId: string | undefined = eventData?.after; - if (eventData?.after !== undefined) { - const previousIndex = cache.getIndexOf(eventData.after); + if (afterId) { + const previousIndex = cache.getIndexOf(afterId); if (previousIndex < 0) { - logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`); + logger.warning(LogOrigin.Server, `Could not find event with id ${afterId}`); } else { atIndex = previousIndex + 1; } @@ -79,7 +85,7 @@ export async function addEvent(eventData: EventPostPayload): Promise 0) { - afterId = cache.getPersistedRundown()[atIndex - 1].id; + afterId = cache.getIdOf(atIndex - 1); } } } @@ -95,14 +101,14 @@ export async function addEvent(eventData: EventPostPayload): Promise, customFields: Readonly) { +export async function initRundown(rundown: Readonly, customFields: Readonly) { await cache.init(rundown, customFields); // notify runtime that rundown has changed diff --git a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts index bff811e2da..7bc16bf756 100644 --- a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts +++ b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts @@ -1,4 +1,5 @@ -import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types'; +import { SupportedEvent, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types'; +import { defaultRundown } from '../../../models/dataModel.js'; const baseEvent = { type: SupportedEvent.Event, @@ -6,6 +7,10 @@ const baseEvent = { revision: 1, }; +const baseBlock = { + type: SupportedEvent.Block, +}; + /** * Utility to create a Ontime event */ @@ -19,8 +24,25 @@ export function makeOntimeEvent(patch: Partial): OntimeEvent { /** * Utility to create a delay event */ -export function makeOntimeDelay(duration: number): OntimeDelay { - return { id: 'delay', type: SupportedEvent.Delay, duration }; +export function makeOntimeDelay(patch: Partial): OntimeDelay { + return { id: 'delay', type: SupportedEvent.Delay, duration: 0, ...patch } as OntimeDelay; +} + +/** + * Utility to create a block event + */ +export function makeOntimeBlock(patch: Partial): OntimeBlock { + return { id: 'block', ...baseBlock, ...patch } as OntimeBlock; +} + +/** + * Utility to create a rundown object + */ +export function makeRundown(patch: Partial): Rundown { + return { + ...defaultRundown, + ...patch, + }; } /** diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index a88bbec3ae..5f296e0db8 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -1,61 +1,84 @@ -import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; +import { OntimeEvent, SupportedEvent } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { apply } from '../delayUtils.js'; -import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; +import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; describe('apply()', () => { it('applies a positive delay to the rundown', () => { - const testRundown = [ - makeOntimeDelay(10), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), - { id: '3', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), - ]; - - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).not.toBe(testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, - { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, - { id: '3' }, - { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, - { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, - ]); + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.revision).toBe(1); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, + '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, + }); }); it('applies negative delays', () => { - const testRundown = [ - makeOntimeDelay(-10), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), - { id: '3', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), - ]; - - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, - { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, - { id: '3' }, - { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, - { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, - ]); + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.revision).toBe(1); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, + '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, + }); }); it('should account for minimum duration and start when applying negative delays', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(-50), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), - ]; - - const expected = [ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent, - { + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 2, + } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event, timeStart: 50, @@ -63,173 +86,222 @@ describe('apply()', () => { duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]; - - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject(expected); + }, + }); }); it('unlinks events to maintain gaps when applying positive delays', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(50), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; - - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('maintains links if there is no gap', () => { - const testRundown = [ - makeOntimeDelay(50), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; - - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent, - { + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 50, + timeEnd: 150, + duration: 100, + revision: 2, + }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: '1', revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('unlinks events to maintain gaps when applying negative delays', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(-50), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; - - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 50, timeEnd: 100, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('gaps reduce positive delay', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(100), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - // gap 50 - makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), - // gap 0 - makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), - // gap 50 - makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), - // linked - makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + // gap 50 + '2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), + // gap 0 + '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), + // gap 50 + '4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), + // linked + '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, // gap 50 (100 - 50) - { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, + '2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, // gap 50 (50 - 50) - { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, + '3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, // gap (delay is 0) - { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, + '4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, // linked - { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, - ]); + '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, + }); }); it('gaps reduce positive delay (2)', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(2 * MILLIS_PER_HOUR), - makeOntimeEvent({ - id: '1', - gap: 0, - dayOffset: 0, - timeStart: 46800000, // 13:00:00 - timeEnd: 50400000, // 14:00:00 - duration: MILLIS_PER_HOUR, - }), - // gap 1h - makeOntimeEvent({ - id: '2', - gap: 1 * MILLIS_PER_HOUR, - dayOffset: 0, - timeStart: 54000000, // 15:00:00 - timeEnd: 57600000, // 16:00:00 - duration: MILLIS_PER_HOUR, - }), - ]; - - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }), + '1': makeOntimeEvent({ + id: '1', + gap: 0, + dayOffset: 0, + timeStart: 46800000, // 13:00:00 + timeEnd: 50400000, // 14:00:00 + duration: MILLIS_PER_HOUR, + }), + // gap 1h + '2': makeOntimeEvent({ + id: '2', + gap: 1 * MILLIS_PER_HOUR, + dayOffset: 0, + timeStart: 54000000, // 15:00:00 + timeEnd: 57600000, // 16:00:00 + duration: MILLIS_PER_HOUR, + }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, // gap 1h (2h - 1h) - { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, - ]); + '2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, + }); }); it('removes empty delays without applying changes', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(0), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 0 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1']); + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); }); it('removes delays in last position without applying changes', () => { - const testRundown: OntimeRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - makeOntimeDelay(100), - ]; + const testRundown = makeRundown({ + order: ['1', 'delay'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1']); + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); }); it('unlinks events to across blocks is it is the first event after the delay', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(50), - { id: 'block', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { id: 'block', type: SupportedEvent.Block }, - { + const testRundown = makeRundown({ + order: ['1', 'delay', 'block', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + block: makeOntimeBlock({ id: 'block' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', 'block', '2']); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + block: { id: 'block' }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index ce4dbec6a5..399c36baf3 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -1,13 +1,4 @@ -import { - CustomFields, - EventCustomFields, - OntimeBlock, - OntimeDelay, - OntimeEvent, - OntimeRundown, - SupportedEvent, - TimeStrategy, -} from 'ontime-types'; +import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; import { @@ -23,6 +14,7 @@ import { removeCustomField, customFieldChangelog, } from '../rundownCache.js'; +import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -39,13 +31,16 @@ beforeAll(() => { describe('generate()', () => { it('creates normalised versions of a given rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1' } as OntimeEvent, - { type: SupportedEvent.Block, id: '2' } as OntimeBlock, - { type: SupportedEvent.Delay, id: '3' } as OntimeDelay, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeBlock({ id: '2' }), + '3': makeOntimeDelay({ id: '3' }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(3); expect(initResult.order).toStrictEqual(['1', '2', '3']); expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event); @@ -54,29 +49,35 @@ describe('generate()', () => { }); it('calculates delays versions of a given rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 1, timeEnd: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeDelay({ id: '1', duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 1, timeEnd: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(2); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100); expect(initResult.totalDelay).toBe(100); }); it('accounts for gaps in rundown when calculating delays', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(7); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200); @@ -87,76 +88,84 @@ describe('generate()', () => { }); it('accounts for overlaps in rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start }); it('accounts for overlaps in rundown (with added gap)', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + '4': makeOntimeEvent({ id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 }), + }, + }); + + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start }); it('accounts for overlaps in rundown (with multiple days)', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, - timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, - duration: 30 * MILLIS_PER_MINUTE, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '4', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, + timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, + duration: 30 * MILLIS_PER_MINUTE, + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + duration: MILLIS_PER_HOUR, + }), + '4': makeOntimeEvent({ + id: '4', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start }); it('handles negative delays', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: -200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(7); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200); @@ -167,38 +176,38 @@ describe('generate()', () => { }); it('links times across events', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 1, - duration: 1, - timeEnd: 2, - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 11, - duration: 1, - timeEnd: 12, - linkStart: '1', - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block' } as OntimeBlock, - { type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 21, - duration: 1, - timeEnd: 22, - linkStart: '2', - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', 'block', 'delay', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 1, + duration: 1, + timeEnd: 2, + timeStrategy: TimeStrategy.LockEnd, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 11, + duration: 1, + timeEnd: 12, + linkStart: '1', + timeStrategy: TimeStrategy.LockEnd, + }), + block: makeOntimeBlock({ id: 'block' }), + delay: makeOntimeDelay({ id: 'delay' }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 21, + duration: 1, + timeEnd: 22, + linkStart: '2', + timeStrategy: TimeStrategy.LockEnd, + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(5); expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2); expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12); @@ -213,13 +222,16 @@ describe('generate()', () => { }); it('links times across events, reordered', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '3', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }), + '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' }), + '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(3); expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2); expect(initResult.links['1']).toBe('3'); @@ -227,159 +239,156 @@ describe('generate()', () => { }); it('calculates total duration', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'skipped', - skip: true, - timeStart: 300, - timeEnd: 400, - duration: 100, - } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); it('calculates total duration with 0 duration events without causing a next day', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'skipped', - skip: true, - timeStart: 300, - timeEnd: 400, - duration: 0, - } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); it('calculates total duration across days with gap', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '3': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = generate(rundown); expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); }); it('calculates total duration across days', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 12 * MILLIS_PER_HOUR, - timeEnd: 22 * MILLIS_PER_HOUR, - duration: 10 * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 22 * MILLIS_PER_HOUR, - timeEnd: 8 * MILLIS_PER_HOUR, - duration: (24 - 22 + 8) * MILLIS_PER_HOUR, - } as OntimeEvent, - ]; - - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 12 * MILLIS_PER_HOUR, + timeEnd: 22 * MILLIS_PER_HOUR, + duration: 10 * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 22 * MILLIS_PER_HOUR, + timeEnd: 8 * MILLIS_PER_HOUR, + duration: (24 - 22 + 8) * MILLIS_PER_HOUR, + }), + }, + }); + + const initResult = generate(rundown); const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); expect(initResult.totalDuration).toBe(expectedDuration); }); it('handles updating event sequence', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '97cc3e', - timeStart: 0, - timeEnd: 600000, - duration: 600000, - timeStrategy: TimeStrategy.LockDuration, - linkStart: null, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'e01948', - timeStart: 600000, - timeEnd: 601000, - duration: 85801000, // <------------- value out of sync - timeStrategy: TimeStrategy.LockEnd, - linkStart: '97cc3e', - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '25c1af', - timeStart: 100, // <------------- value out of sync - timeEnd: 602000, - duration: 0, - timeStrategy: TimeStrategy.LockEnd, - linkStart: 'e01948', - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 0, + timeEnd: 600000, + duration: 600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: null, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 600000, + timeEnd: 601000, + duration: 85801000, // <------------- value out of sync + timeStrategy: TimeStrategy.LockEnd, + linkStart: '1', + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 100, // <------------- value out of sync + timeEnd: 602000, + duration: 0, + timeStrategy: TimeStrategy.LockEnd, + linkStart: '2', + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.rundown).toMatchObject({ - '97cc3e': { + '1': { timeStart: 0, timeEnd: 600000, duration: 600000, timeStrategy: 'lock-duration', linkStart: null, }, - e01948: { + '2': { timeStart: 600000, timeEnd: 601000, duration: 1000, timeStrategy: 'lock-end', - linkStart: '97cc3e', + linkStart: '1', }, - '25c1af': { + '3': { timeStart: 601000, timeEnd: 602000, duration: 1000, timeStrategy: 'lock-end', - linkStart: 'e01948', + linkStart: '2', }, }); }); it('deletes links if invalid', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent, - ]; - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(1); expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1); expect(Object.keys(initResult.links).length).toBe(0); @@ -399,24 +408,26 @@ describe('generate()', () => { colour: 'red', }, }; - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - custom: { - lighting: 'event 1 lx', - } as EventCustomFields, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - custom: { - lighting: 'event 2 lx', - sound: 'event 2 sound', - } as EventCustomFields, - } as OntimeEvent, - ]; - const initResult = generate(testRundown, customProperties); + + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + custom: { + lighting: 'event 1 lx', + }, + }), + '2': makeOntimeEvent({ + id: '2', + custom: { + lighting: 'event 2 lx', + sound: 'event 2 sound', + }, + }), + }, + }); + const initResult = generate(rundown, customProperties); expect(initResult.order.length).toBe(2); expect(initResult.assignedCustomFields).toMatchObject({ lighting: ['1', '2'], @@ -433,47 +444,64 @@ describe('generate()', () => { describe('add() mutation', () => { test('adds an event to the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const testRundown: OntimeRundown = []; - const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown }); - expect(newRundown.length).toBe(1); - expect(newRundown[0]).toMatchObject(mockEvent); + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({}); + const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown }); + expect(newRundown.order.length).toBe(1); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); }); }); describe('remove() mutation', () => { test('deletes an event from the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const testRundown: OntimeRundown = [mockEvent]; - const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown }); - expect(newRundown.length).toBe(0); + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + order: ['mock'], + entries: { + mock: mockEvent, + }, + }); + + const { newRundown } = remove({ eventIds: [mockEvent.id], rundown }); + expect(newRundown.order.length).toBe(0); }); + test('deletes multiple events from the rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1' } as OntimeEvent, - { type: SupportedEvent.Block, id: '2' } as OntimeBlock, - { type: SupportedEvent.Delay, id: '3' } as OntimeDelay, - { type: SupportedEvent.Event, id: '4' } as OntimeEvent, - { type: SupportedEvent.Event, id: '5' } as OntimeEvent, - { type: SupportedEvent.Event, id: '6' } as OntimeEvent, - ]; - const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown }); - expect(newRundown.length).toBe(3); - expect(newRundown.at(0)?.id).toBe('4'); + const rundown = makeRundown({ + order: ['1', '2', '3', '4', '5', '6'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeBlock({ id: '2' }), + '3': makeOntimeDelay({ id: '3' }), + '4': makeOntimeEvent({ id: '4' }), + '5': makeOntimeEvent({ id: '5' }), + '6': makeOntimeEvent({ id: '6' }), + }, + }); + + const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown }); + expect(newRundown.order.length).toBe(3); + expect(newRundown.entries[newRundown.order[0]].id).toBe('4'); }); }); describe('edit() mutation', () => { test('edits an event in the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const mockEventPatch = { cue: 'patched' } as OntimeEvent; - const testRundown: OntimeRundown = [mockEvent]; + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const mockEventPatch = makeOntimeEvent({ cue: 'patched' }); + const rundown = makeRundown({ + order: ['mock'], + entries: { + mock: mockEvent, + }, + }); + const { newRundown, newEvent } = edit({ eventId: mockEvent.id, patch: mockEventPatch, - rundown: testRundown, + rundown, }); - expect(newRundown.length).toBe(1); + expect(newRundown.order.length).toBe(1); expect(newEvent).toMatchObject({ id: 'mock', cue: 'patched', @@ -484,73 +512,96 @@ describe('edit() mutation', () => { describe('batchEdit() mutation', () => { it('should correctly apply the patch to the events with the given IDs', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + const eventIds = ['1', '3']; const patch = { cue: 'newData' }; - const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch }); + const { newRundown } = batchEdit({ rundown, eventIds, patch }); - expect(newRundown).toMatchObject([ - { id: '1', type: SupportedEvent.Event, cue: 'newData' }, - { id: '2', type: SupportedEvent.Event, cue: 'data2' }, - { id: '3', type: SupportedEvent.Event, cue: 'newData' }, - ]); + expect(newRundown.entries).toMatchObject({ + '1': { id: '1', type: SupportedEvent.Event, cue: 'newData' }, + '2': { id: '2', type: SupportedEvent.Event, cue: 'data2' }, + '3': { id: '3', type: SupportedEvent.Event, cue: 'newData' }, + }); }); }); describe('reorder() mutation', () => { it('should correctly reorder two events', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }), + '2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }), + '3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }), + }, + }); + + // move first event to the end const { newRundown } = reorder({ - rundown: testRundown, - eventId: testRundown[0].id, + rundown: rundown, + eventId: rundown.order[0], from: 0, - to: testRundown.length - 1, + to: rundown.order.length - 1, }); - expect(newRundown).toMatchObject([ - { id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 1 }, - { id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 1 }, - { id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 1 }, - ]); + expect(newRundown.order).toStrictEqual(['2', '3', '1']); + expect(newRundown.entries).toMatchObject({ + '2': { id: '2', cue: 'data2', revision: 1 }, + '3': { id: '3', cue: 'data3', revision: 1 }, + '1': { id: '1', cue: 'data1', revision: 1 }, + }); }); }); describe('swap() mutation', () => { it('should correctly swap data between events', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1, revision: 4 }), + '2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2, revision: 8 }), + '3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3, revision: 12 }), + }, + }); + + // swap first and second event const { newRundown } = swap({ - rundown: testRundown, - fromId: testRundown[0].id, - toId: testRundown[1].id, + rundown: rundown, + fromId: rundown.order[0], + toId: rundown.order[1], }); - expect((newRundown[0] as OntimeEvent).id).toBe('1'); - expect((newRundown[0] as OntimeEvent).cue).toBe('data2'); - expect((newRundown[0] as OntimeEvent).timeStart).toBe(1); - expect((newRundown[0] as OntimeEvent).revision).toBe(1); + expect(newRundown.order).toStrictEqual(['1', '2', '3']); - expect((newRundown[1] as OntimeEvent).id).toBe('2'); - expect((newRundown[1] as OntimeEvent).cue).toBe('data1'); - expect((newRundown[1] as OntimeEvent).timeStart).toBe(2); - expect((newRundown[1] as OntimeEvent).revision).toBe(1); + expect(newRundown.entries['1']).toMatchObject({ + id: '1', + cue: 'data2', + timeStart: 1, + revision: 5, + }); - expect((newRundown[2] as OntimeEvent).id).toBe('3'); - expect((newRundown[2] as OntimeEvent).cue).toBe('data3'); - expect((newRundown[2] as OntimeEvent).timeStart).toBe(3); - expect((newRundown[2] as OntimeEvent).revision).toBe(0); + expect(newRundown.entries['2']).toMatchObject({ + id: '2', + cue: 'data1', + timeStart: 2, + revision: 9, + }); + + expect(newRundown.entries['3']).toMatchObject({ + id: '3', + cue: 'data3', + timeStart: 3, + revision: 12, + }); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts index 5904e38fdc..a8f4b832bf 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts @@ -2,7 +2,7 @@ import { CustomFields, EndAction, OntimeEvent, - OntimeRundown, + RundownEntries, SupportedEvent, TimeStrategy, TimerType, @@ -10,46 +10,25 @@ import { import { addToCustomAssignment, calculateDayOffset, - getLink, handleCustomField, handleLink, hasChanges, isDataStale, } from '../rundownCacheUtils.js'; import { MILLIS_PER_HOUR } from 'ontime-utils'; - -describe('getLink()', () => { - it('should return null if there is no link', () => { - const rundown = [ - { type: SupportedEvent.Block, id: 'block' }, - { type: SupportedEvent.Event, id: '1' }, - ] as OntimeRundown; - - const result = getLink(1, rundown); - expect(result).toBeNull(); - }); - - it('returns previous event', () => { - const rundown = [ - { type: SupportedEvent.Event, id: '1', timeEnd: 100 }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - - const result = getLink(1, rundown); - expect(result.id).toBe('1'); - }); -}); +import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; describe('handleLink()', () => { it('populates data in object and updates link map', () => { - const rundown = [ - { type: SupportedEvent.Event, id: '1', timeEnd: 100 }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - const mutableEvent = { ...rundown[1] } as OntimeEvent; + const entries: RundownEntries = { + '1': makeOntimeEvent({ id: '1', timeEnd: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), + }; + + const mutableEvent = { ...entries[2] } as OntimeEvent; const links = {}; - const result = handleLink(1, rundown, mutableEvent, links); + const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links); expect(result).toBeUndefined(); expect(mutableEvent.timeStart).toBe(100); expect(mutableEvent.linkStart).toBe('1'); @@ -57,17 +36,17 @@ describe('handleLink()', () => { }); it('removes link if linked event is not found', () => { - const rundown = [ - { type: SupportedEvent.Block, id: '1' }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - const mutableEvent = { ...rundown[1] } as OntimeEvent; + const entries: RundownEntries = { + '1': makeOntimeBlock({ id: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), + }; + const mutableEvent = { ...entries[2] } as OntimeEvent; const links = {}; - const result = handleLink(1, rundown, mutableEvent, links); + const result = handleLink(mutableEvent, null, links); expect(result).toBeUndefined(); expect(mutableEvent.timeStart).toBe(0); - expect(mutableEvent.linkStart).toBe(null); + expect(mutableEvent.linkStart).toBe('true'); expect(links).toStrictEqual({}); }); }); @@ -252,7 +231,7 @@ describe('hasChanges()', () => { describe('calculateDayOffset', () => { it('returns 0 if there is no previous event', () => { - expect(calculateDayOffset({ timeStart: 0 })).toBe(0); + expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0); }); it('returns 0 if the previous event duration is 0', () => { diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index f5264dc4d8..8d0d7b462b 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,38 +1,41 @@ -import { OntimeRundown, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; +import { Rundown, EntryId, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { deleteAtIndex } from 'ontime-utils'; /** * Applies delay from given event ID, deletes the delay event after - * @throws {Error} if event ID not found or is not a delay + * Mutates the given rundown + * @throws if event ID not found or is not a delay */ -export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { - const delayIndex = rundown.findIndex((event) => event.id === eventId); - const delayEvent = rundown.at(delayIndex); +export function apply(delayId: EntryId, rundown: Rundown): Rundown { + const delayEvent = rundown.entries[delayId]; - if (!delayEvent) { - throw new Error('Given event ID not found'); + if (!delayEvent || !isOntimeDelay(delayEvent)) { + throw new Error('Given delay ID not found'); } - if (!isOntimeDelay(delayEvent)) { - throw new Error('Given event ID is not a delay'); - } + const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId); - // if the delay is empty, or the last element, we can just delete it - if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) { - return deleteAtIndex(delayIndex, rundown); + // if the delay is empty, or the last element + // we can just delete it with no further operations + if (delayEvent.duration === 0 || delayIndex === rundown.order.length - 1) { + delete rundown.entries[delayId]; + rundown.order = deleteAtIndex(delayIndex, rundown.order); + return rundown; } /** * We apply the delay to the rundown * This logic is mostly in sync with rundownCache.generate + * The difference is that here it will become part of the schedule, + * so we cant leave the work for the generate function */ - const updatedRundown = structuredClone(rundown); let delayValue = delayEvent.duration; let lastEntry: OntimeEvent | null = null; let isFirstEvent = true; - for (let i = delayIndex + 1; i < updatedRundown.length; i++) { - const currentEntry = updatedRundown[i]; + for (let i = delayIndex + 1; i < rundown.order.length; i++) { + const currentId = rundown.order[i]; + const currentEntry = rundown.entries[currentId]; // we don't do operation on other event types if (!isOntimeEvent(currentEntry)) { @@ -77,5 +80,9 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { currentEntry.revision += 1; } - return deleteAtIndex(delayIndex, updatedRundown); + delete rundown.entries[delayId]; + rundown.order = deleteAtIndex(delayIndex, rundown.order); + rundown.revision += 1; + + return rundown; } diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 954c507ce2..5a2ce87c60 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -2,15 +2,18 @@ import { CustomField, CustomFieldLabel, CustomFields, + EntryId, isOntimeBlock, isOntimeDelay, isOntimeEvent, isPlayableEvent, MaybeNumber, + OntimeBlock, OntimeEvent, - OntimeRundown, - OntimeRundownEntry, + OntimeEntry, PlayableEvent, + Rundown, + RundownEntries, } from 'ontime-types'; import { generateId, @@ -21,26 +24,32 @@ import { isNewLatest, customFieldLabelToKey, } from 'ontime-utils'; + import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.js'; + import { apply } from './delayUtils.js'; import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js'; -type EventID = string; -type NormalisedRundown = Record; - -let persistedRundown: OntimeRundown = []; +let currentRundownId: EntryId = ''; +let currentRundown: Rundown = { + id: '', + title: '', + order: [], + entries: {}, + revision: 0, +}; let persistedCustomFields: CustomFields = {}; /** * Get the cached rundown without triggering regeneration */ -export const getPersistedRundown = (): OntimeRundown => persistedRundown; +export const getCurrentRundown = (): Rundown => currentRundown; export const getCustomFields = (): CustomFields => persistedCustomFields; -let normalisedRundown: NormalisedRundown = {}; -let order: EventID[] = []; -let revision = 0; +let playableEventsOrder: EntryId[] = []; +let timedEventsOrder: EntryId[] = []; +let flatIndexOrder: EntryId[] = []; /** * all mutating functions will set this value if there is a need for re-generation @@ -59,7 +68,7 @@ let totalDays = 0; let firstStart: MaybeNumber = null; let lastEnd: MaybeNumber = null; -let links: Record = {}; +let links: Record = {}; /** * Object that contains reference of renamed custom fields @@ -76,13 +85,17 @@ export const customFieldChangelog = new Map(); * Keep track of which custom fields are used. * This will be handy for when we delete custom fields */ -let assignedCustomFields: Record = {}; +let assignedCustomFields: Record = {}; -export async function init(initialRundown: Readonly, customFields: Readonly) { - persistedRundown = structuredClone(initialRundown) as OntimeRundown; +/** + * Receives a rundown which will be processed and used as the new current rundown + */ +export async function init(initialRundown: Rundown, customFields: Readonly) { + currentRundown = structuredClone(initialRundown); + currentRundownId = initialRundown.id; persistedCustomFields = structuredClone(customFields); generate(); - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); await getDataProvider().setCustomFields(customFields); } @@ -90,10 +103,7 @@ export async function init(initialRundown: Readonly, customFields * Utility generate cache * @private should not be called outside of `rundownCache.ts` */ -export function generate( - initialRundown: OntimeRundown = persistedRundown, - customFields: CustomFields = persistedCustomFields, -) { +export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) { function clearIsStale() { isStale = false; } @@ -102,8 +112,10 @@ export function generate( // instead of maintaining logic to update it assignedCustomFields = {}; - normalisedRundown = {}; - order = []; + playableEventsOrder = []; + timedEventsOrder = []; + flatIndexOrder = []; + links = {}; firstStart = null; lastEnd = null; @@ -111,20 +123,30 @@ export function generate( totalDays = 0; totalDelay = 0; + // temporary parsed rundown + const parsedEntries: RundownEntries = {}; + const parsedOrder: EntryId[] = []; + + /** A playableEvent from the previous iteration */ + let previousEntry: PlayableEvent | null = null; + /** The playableEvent most forwards in time processed so far */ let lastEntry: PlayableEvent | null = null; - for (let i = 0; i < initialRundown.length; i++) { + for (let i = 0; i < initialRundown.order.length; i++) { // we assign a reference to the current entry, this will be mutated in place - const currentEntry = initialRundown[i]; + const currentEntryId = initialRundown.order[i]; + const currentEntry = initialRundown.entries[currentEntryId]; + flatIndexOrder.push(currentEntryId); if (isOntimeEvent(currentEntry)) { currentEntry.delay = 0; currentEntry.gap = 0; + timedEventsOrder.push(currentEntryId); - // 1. handle links - mutates updatedEvent - handleLink(i, initialRundown, currentEntry, links); + // 1. handle links - mutates currentEntry and links + handleLink(currentEntry, previousEntry, links); - // 2. handle custom fields - mutates updatedEvent + // 2. handle custom fields - mutates currentEntry handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields); totalDays += calculateDayOffset(currentEntry, lastEntry); @@ -132,6 +154,7 @@ export function generate( // update rundown metadata, it only concerns playable events if (isPlayableEvent(currentEntry)) { + playableEventsOrder.push(currentEntryId); // fist start is always the first event if (firstStart === null) { firstStart = currentEntry.timeStart; @@ -160,6 +183,7 @@ export function generate( // current event delay is the current accumulated delay currentEntry.delay = totalDelay; + previousEntry = currentEntry; // lastEntry is the event with the latest end time if (isNewLatest(currentEntry, lastEntry)) { lastEntry = currentEntry; @@ -178,17 +202,21 @@ export function generate( } // add id to order - order.push(currentEntry.id); + parsedOrder.push(currentEntry.id); // add entry to rundown - normalisedRundown[currentEntry.id] = currentEntry; + parsedEntries[currentEntry.id] = currentEntry; } lastEnd = lastEntry?.timeEnd ?? null; clearIsStale(); customFieldChangelog.clear(); - //The return value is used for testing - return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields }; + // update the cache values + currentRundown.entries = parsedEntries; + currentRundown.order = parsedOrder; + + // The return value is used for testing + return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields }; } /** Returns an ID guaranteed to be unique */ @@ -199,21 +227,31 @@ export function getUniqueId(): string { let id = ''; do { id = generateId(); - } while (Object.hasOwn(normalisedRundown, id)); + } while (Object.hasOwn(currentRundown.entries, id)); return id; } /** Returns index of an event with a given id */ -export function getIndexOf(eventId: string) { +export function getIndexOf(eventId: EntryId) { + if (isStale) { + generate(); + } + return currentRundown.order.indexOf(eventId); +} + +/** Returns id of an event at a given index */ +export function getIdOf(index: number) { if (isStale) { generate(); } - return order.indexOf(eventId); + return currentRundown.order.at(index); } type RundownCache = { - rundown: NormalisedRundown; - order: string[]; + id: string; + title: string; + order: EntryId[]; + entries: RundownEntries; revision: number; totalDelay: number; totalDuration: number; @@ -228,19 +266,29 @@ export function get(): Readonly { generate(); } return { - rundown: normalisedRundown, - order, - revision, + id: currentRundown.id, + title: currentRundown.title, + entries: currentRundown.entries, + order: currentRundown.order, + revision: currentRundown.revision, totalDelay, totalDuration, }; } +export type RundownMetadata = { + firstStart: MaybeNumber; + lastEnd: MaybeNumber; + totalDelay: number; + totalDuration: number; + revision: number; +}; + /** * Returns calculated metadata from rundown * Will triggering regeneration if data is stale. */ -export function getMetadata() { +export function getMetadata(): Readonly { if (isStale) { generate(); } @@ -250,15 +298,35 @@ export function getMetadata() { lastEnd, totalDelay, totalDuration, - revision, + revision: currentRundown.revision, + }; +} + +export type RundownOrder = { + order: EntryId[]; + timedEventsOrder: EntryId[]; + playableEventsOrder: EntryId[]; +}; + +/** + * Exposes the order of events + */ +export function getEventOrder(): Readonly { + if (isStale) { + generate(); + } + return { + order: currentRundown.order, + timedEventsOrder, + playableEventsOrder, }; } -type CommonParams = { rundown: OntimeRundown }; +type CommonParams = { rundown: Rundown }; type MutationParams = T & CommonParams; type MutatingReturn = { - newRundown: OntimeRundown; - newEvent?: OntimeRundownEntry; + newRundown: Rundown; + newEvent?: OntimeEntry; didMutate: boolean; }; type MutatingFn = (params: MutationParams) => MutatingReturn; @@ -269,15 +337,17 @@ type MutatingFn = (params: MutationParams) => MutatingRetur */ export function mutateCache(mutation: MutatingFn) { function scopedMutation(params: T) { - const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown }); + // we work on a copy of the rundown + const rundownCopy = structuredClone(currentRundown); + const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: rundownCopy }); // early return without calling side effects if (!didMutate) { return { newEvent, newRundown, didMutate }; } - revision = revision + 1; - persistedRundown = newRundown; + newRundown.revision += 1; + currentRundown = newRundown; // schedule a non priority cache update setImmediate(() => { @@ -286,7 +356,7 @@ export function mutateCache(mutation: MutatingFn) { // defer writing to the database setImmediate(async () => { - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); }); return { newEvent, newRundown, didMutate }; @@ -295,70 +365,91 @@ export function mutateCache(mutation: MutatingFn) { return scopedMutation; } -type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>; +type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>; /** * Add entry to rundown */ export function add({ rundown, atIndex, event }: AddArgs): Required { - const newEvent: OntimeRundownEntry = { ...event }; - const newRundown = insertAtIndex(atIndex, newEvent, rundown); + const newEvent: OntimeEntry = { ...event }; + + rundown.entries[newEvent.id] = newEvent; + rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order); setIsStale(); - return { newRundown, newEvent, didMutate: true }; + return { newRundown: rundown, newEvent, didMutate: true }; } -type RemoveArgs = MutationParams<{ eventIds: string[] }>; +type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>; /** * Remove entry to rundown */ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { - const newRundown = rundown.filter((event) => !eventIds.includes(event.id)); - const didMutate = rundown.length !== newRundown.length; + const previousLength = rundown.order.length; + rundown.order = rundown.order.filter((id) => !eventIds.includes(id)); + for (const id of eventIds) { + delete rundown.entries[id]; + } + const didMutate = rundown.order.length !== previousLength; if (didMutate) setIsStale(); - return { newRundown, didMutate }; + return { newRundown: rundown, didMutate }; } export function removeAll(): MutatingReturn { setIsStale(); - return { newRundown: [], didMutate: true }; + return { + newRundown: { + id: '', + title: '', + order: [], + entries: {}, + revision: 0, + }, + didMutate: true, + }; } /** * Utility function for patching an existing event with new data */ -function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial): OntimeRundownEntry { +function makeEvent(eventFromRundown: T, patch: Partial): T { if (isOntimeEvent(eventFromRundown)) { - const newEvent = createPatch(eventFromRundown, patch as OntimeEvent); + const newEvent = createPatch(eventFromRundown, patch as Partial); + newEvent.revision++; + return newEvent as T; + } + if (isOntimeBlock(eventFromRundown)) { + const newEvent: OntimeBlock = { ...eventFromRundown, ...patch }; newEvent.revision++; - return newEvent; + return newEvent as T; } - // TODO: exhaustive check - return { ...eventFromRundown, ...patch } as OntimeRundownEntry; + + return { ...eventFromRundown, ...patch } as T; } -type EditArgs = MutationParams<{ eventId: string; patch: Partial }>; +type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial }>; /** * Apply patch to an entry with given id */ export function edit({ rundown, eventId, patch }: EditArgs): Required { - const indexAt = rundown.findIndex((event) => event.id === eventId); - if (indexAt < 0) { - throw new Error('Event not found'); + const entry = rundown.entries[eventId]; + if (!entry) { + // there should be no reason for the entry not to be found + // check if it exists in the rundown order + rundown.order = rundown.order.filter((id) => id !== eventId); + throw new Error('Entry not found'); } - if (patch?.type && rundown[indexAt].type !== patch.type) { + // we cannot allow patching to a different type + if (patch?.type && entry.type !== patch.type) { throw new Error('Invalid event type'); } - const eventInMemory = rundown[indexAt]; - - if (!hasChanges(eventInMemory, patch)) { - return { newRundown: rundown, newEvent: eventInMemory, didMutate: false }; + // if nothing changed, nothing to do + if (!hasChanges(entry, patch)) { + return { newRundown: rundown, newEvent: entry, didMutate: false }; } - const newEvent = makeEvent(eventInMemory, patch); - - const newRundown = [...rundown]; - newRundown[indexAt] = newEvent; + const newEvent = makeEvent(entry, patch); + rundown.entries[newEvent.id] = newEvent; // check whether the data warrants recalculation of cache const makeStale = isDataStale(patch); @@ -366,91 +457,77 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required }>; +type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial }>; /** * Apply patch to multiple entries */ export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn { - const ids = new Set(eventIds); - - const newRundown = []; - for (let i = 0; i < rundown.length; i++) { - if (ids.has(rundown[i].id)) { - if (patch?.type && rundown[i].type !== patch.type) { - continue; - } - const newEvent = makeEvent(rundown[i], patch); - newRundown.push(newEvent); - } else { - newRundown.push(rundown[i]); - } + for (const eventId of eventIds) { + edit({ rundown, eventId, patch }); } - setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } -type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; +type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>; /** - * Redorder two entries + * Reorder two entries */ export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required { - const event = rundown[from]; - if (!event || eventId !== event.id) { + const eventFrom = rundown.entries[eventId]; + if (!eventFrom) { throw new Error('Event not found'); } - const newRundown = reorderArray(rundown, from, to); + rundown.order = reorderArray(rundown.order, from, to); + + // increment revision of all events in between for (let i = from; i <= to; i++) { - const event = newRundown.at(i); - if (isOntimeEvent(event)) { - event.revision += 1; + const eventId = rundown.order[i]; + const entry = rundown.entries[eventId]; + if (isOntimeEvent(entry) || isOntimeBlock(entry)) { + entry.revision += 1; } } + setIsStale(); - return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true }; + return { newRundown: rundown, newEvent: eventFrom, didMutate: true }; } -type ApplyDelayArgs = MutationParams<{ eventId: string }>; +type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>; /** * Apply a delay */ -export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn { - const newRundown = apply(eventId, rundown); +export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn { + apply(delayId, rundown); setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } -type SwapArgs = MutationParams<{ fromId: string; toId: string }>; +type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries */ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { - const indexA = rundown.findIndex((event) => event.id === fromId); - const eventA = rundown.at(indexA); - - const indexB = rundown.findIndex((event) => event.id === toId); - const eventB = rundown.at(indexB); + const fromEvent = rundown.entries[fromId]; + const toEvent = rundown.entries[toId]; - if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { + if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) { throw new Error('Swap only available for OntimeEvents'); } - const { newA, newB } = swapEventData(eventA, eventB); - const newRundown = [...rundown]; + const [newFrom, newTo] = swapEventData(fromEvent, toEvent); - newRundown[indexA] = newA; - (newRundown[indexA] as OntimeEvent).revision += 1; - newRundown[indexB] = newB; - (newRundown[indexB] as OntimeEvent).revision += 1; + rundown.entries[fromId] = newFrom; + rundown.entries[toId] = newTo; setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } /** @@ -468,7 +545,7 @@ function invalidateIfUsed(label: CustomFieldLabel) { // schedule a non priority cache update setImmediate(async () => { generate(); - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); }); } diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts index a1cbe75669..9eb6418cb9 100644 --- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts +++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts @@ -1,57 +1,35 @@ -import { - OntimeEvent, - isOntimeEvent, - OntimeRundown, - CustomFieldLabel, - CustomFields, - OntimeRundownEntry, - OntimeBaseEvent, -} from 'ontime-types'; +import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types'; import { dayInMs, getLinkedTimes } from 'ontime-utils'; /** - * Get linked event - */ -export function getLink(currentIndex: number, rundown: OntimeRundown): OntimeEvent | null { - // currently the link is the previous event - for (let i = currentIndex - 1; i >= 0; i--) { - const event = rundown[i]; - if (isOntimeEvent(event) && !event.skip) { - return event; - } - } - return null; -} - -/** - * Populates data from link, if necessary - * Mutates in place mutableEvent - * Mutates in place links + * Checks that link can be established (ie, events exist and are valid) + * and populates the time data from link + * With the current implementation, the links is always the previous playable event + * Mutates mutableEvent in place + * Mutates links in place */ export function handleLink( - currentIndex: number, - rundown: OntimeRundown, mutableEvent: OntimeEvent, + previousEvent: OntimeEvent | null, links: Record, ): void { if (!mutableEvent.linkStart) { return; } - const linkedEvent = getLink(currentIndex, rundown); - if (!linkedEvent) { - mutableEvent.linkStart = null; + /** + * If no previous event exist, we dont remove the link + * this means that the event will keep the behaviour in case a new event is added before + * However, we do add its ID to the links and prevent out-of-sync data + */ + if (!previousEvent) { + mutableEvent.linkStart = 'true'; return; } - // sometimes the client cannot set the previous event - if (mutableEvent.linkStart === 'true') { - mutableEvent.linkStart = linkedEvent.id; - } - - links[linkedEvent.id] = mutableEvent.id; - - const timePatch = getLinkedTimes(mutableEvent, linkedEvent); + const timePatch = getLinkedTimes(mutableEvent, previousEvent); + mutableEvent.linkStart = previousEvent.id; + links[previousEvent.id] = mutableEvent.id; // use object.assign to force mutation Object.assign(mutableEvent, timePatch); } @@ -125,7 +103,7 @@ enum RegenerateWhitelist { * given a patch, returns whether all keys are whitelisted * @param path */ -export function isDataStale(patch: Partial): boolean { +export function isDataStale(patch: Partial): boolean { return Object.keys(patch).some((key) => !(key in RegenerateWhitelist)); } @@ -157,7 +135,7 @@ export function hasChanges(existingEvent: T, newEvent */ export function calculateDayOffset( current: Pick, - previous?: Pick, + previous: Pick | null, ) { // if there is no previous there can't be a day offset if (!previous) { diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index b006681867..0d4ce8a871 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -1,61 +1,90 @@ -import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types'; -import { filterPlayable, filterTimedEvents } from 'ontime-utils'; +import { + OntimeEvent, + Rundown, + OntimeEntry, + PlayableEvent, + EntryId, + RundownEntries, + ProjectRundowns, +} from 'ontime-types'; import * as cache from './rundownCache.js'; /** - * returns the normalised rundown + * returns entire unfiltered rundown */ -export function getNormalisedRundown(): RundownCached { - return cache.get(); +export function getCurrentRundown(): Rundown { + return cache.getCurrentRundown(); } /** - * returns entire unfiltered rundown + * returns the the project rundown and the order arrays */ -export function getRundown(): OntimeRundown { - return cache.getPersistedRundown(); +export function getRundownData() { + return { + rundown: cache.getCurrentRundown(), + rundownOrder: cache.getEventOrder(), + }; } /** * returns all events of type OntimeEvent */ export function getTimedEvents(): OntimeEvent[] { - return filterTimedEvents(getRundown()); + const { entries } = cache.get(); + const { timedEventsOrder } = cache.getEventOrder(); + return makeFlatRundownFromOrder(timedEventsOrder, entries); } /** - * returns all events that can be loaded + * Utility flattens a normalised rundown */ -export function getPlayableEvents(): PlayableEvent[] { - return filterPlayable(getRundown()); +function makeFlatRundownFromOrder(order: EntryId[], events: RundownEntries): T[] { + return order.map((id) => events[id] as T); } /** * returns an event given its index after filtering for OntimeEvents */ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { - const timedEvents = getTimedEvents(); - return timedEvents.at(eventIndex); + const { timedEventsOrder } = cache.getEventOrder(); + const eventId = timedEventsOrder[eventIndex]; + + if (!eventId) { + return undefined; + } + const { entries } = getCurrentRundown(); + return entries[eventId] as OntimeEvent | undefined; } /** * returns first event that matches a given ID */ -export function getEventWithId(eventId: string): OntimeRundownEntry | undefined { - const rundown = getRundown(); - return rundown.find((event) => event.id === eventId); +export function getEventWithId(eventId: string): OntimeEntry | undefined { + const { entries } = getCurrentRundown(); + return entries[eventId]; +} + +/** + * Utility returns the first playable event in rundown + */ +export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined { + const firstEventId = playableOrder.at(0); + if (!firstEventId) return; + return getEventWithId(firstEventId) as PlayableEvent | undefined; } /** * returns first event that matches a given cue */ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined { - const playableEvents = getPlayableEvents(); + const { playableEventsOrder } = cache.getEventOrder(); + const lowerCaseCue = targetCue.toLowerCase(); - for (let i = currentEventIndex; i < playableEvents.length; i++) { - const event = playableEvents.at(i); + for (let i = currentEventIndex; i < playableEventsOrder.length; i++) { + const eventId = playableEventsOrder[i]; + const event = getEventWithId(eventId) as PlayableEvent | undefined; if (event?.cue.toLowerCase() === lowerCaseCue) { return event; } @@ -65,39 +94,74 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O /** * finds the previous event */ -export function findPrevious(currentEventId?: string): OntimeEvent | null { - const playableEvents = getPlayableEvents(); - if (!playableEvents || !playableEvents.length) { - return null; +export function findPrevious(currentEventId?: string): OntimeEvent | undefined { + const { playableEventsOrder } = cache.getEventOrder(); + + if (!playableEventsOrder.length) { + return; } // if there is no event running, go to first if (!currentEventId) { - return playableEvents.at(0) ?? null; + return getFirstPlayable(playableEventsOrder); } - const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); + const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId); const newIndex = Math.max(currentIndex - 1, 0); - const previousEvent = playableEvents.at(newIndex) ?? null; - return previousEvent; + const previousEventId = playableEventsOrder.at(newIndex); + + if (!previousEventId) { + return getFirstPlayable(playableEventsOrder); + } + + return getEventWithId(previousEventId) as PlayableEvent | undefined; } /** * finds the next event */ -export function findNext(currentEventId?: string): PlayableEvent | null { - const playableEvents = getPlayableEvents(); - if (!playableEvents.length) { - return null; +export function findNext(currentEventId?: string): PlayableEvent | undefined { + const { playableEventsOrder } = cache.getEventOrder(); + + if (!playableEventsOrder.length) { + return; } // if there is no event running, go to first if (!currentEventId) { - return playableEvents.at(0) ?? null; + return getFirstPlayable(playableEventsOrder); } - const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); - const newIndex = currentIndex + 1; - const nextEvent = playableEvents.at(newIndex); - return nextEvent ?? null; + const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId); + const newIndex = Math.min(currentIndex + 1, playableEventsOrder.length - 1); + const nextEventId = playableEventsOrder.at(newIndex); + + if (!nextEventId) { + return getFirstPlayable(playableEventsOrder); + } + + return getEventWithId(nextEventId) as PlayableEvent | undefined; +} + +export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] { + return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent); +} + +/** + * Gets the first rundown in the project + * We ensure that the projects always have a rundown + */ +export function getFirstRundown(rundowns: ProjectRundowns): Rundown { + const firstKey = Object.keys(rundowns)[0]; + return rundowns[firstKey]; +} + +/** + * Returns a rundown given its ID + */ +export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): Rundown { + if (!rundowns[rundownId]) { + throw new Error(`Rundown with ID ${rundownId} not found`); + } + return rundowns[rundownId]; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 46232b82a2..071e0b976b 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -31,13 +31,15 @@ import { getEventAtIndex, getNextEventWithCue, getEventWithId, - getRundown, + getCurrentRundown, getTimedEvents, + getRundownData, } from '../rundown-service/rundownUtils.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; import { skippedOutOfEvent } from '../timerUtils.js'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; +import { getEventOrder } from '../rundown-service/rundownCache.js'; type RuntimeStateEventKeys = keyof Pick; @@ -270,8 +272,9 @@ class RuntimeService { if (onlyChangedNow) { runtimeState.updateLoaded(eventNow); } else { - const rundown = getRundown(); - runtimeState.updateAll(rundown); + const rundown = getCurrentRundown(); + const { timedEventsOrder } = getEventOrder(); + runtimeState.updateAll(rundown, timedEventsOrder); } return; } @@ -298,8 +301,8 @@ class RuntimeService { } const previousState = runtimeState.getState(); - const rundown = getRundown(); - const success = runtimeState.load(event, rundown, initialData); + const { rundown, rundownOrder } = getRundownData(); + const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData); if (success) { logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); @@ -583,9 +586,11 @@ class RuntimeService { * Handles special case to call roll on a loaded event which we do not want to discard */ private rollLoaded(offset?: number) { - const rundown = getRundown(); + const rundown = getCurrentRundown(); + const { timedEventsOrder } = getEventOrder(); + try { - runtimeState.roll(rundown, offset); + runtimeState.roll(rundown, timedEventsOrder, offset); } catch (error) { logger.error(LogOrigin.Server, `Roll: ${error}`); } @@ -605,8 +610,8 @@ class RuntimeService { } try { - const rundown = getRundown(); - const result = runtimeState.roll(rundown); + const { rundown, rundownOrder } = getRundownData(); + const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder); const newState = runtimeState.getState(); if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); @@ -657,8 +662,8 @@ class RuntimeService { return; } - const rundown = getRundown(); - runtimeState.resume(restorePoint, event, rundown); + const { rundown, rundownOrder } = getRundownData(); + runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder); logger.info(LogOrigin.Playback, 'Resuming playback'); } diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index a30f148fcf..ef098f04db 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -4,7 +4,7 @@ * @link https://developers.google.com/identity/protocols/oauth2/limited-input-device */ -import { AuthenticationStatus, CustomFields, LogOrigin, MaybeString, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types'; import { ImportMap, getErrorMessage } from 'ontime-utils'; import { sheets, type sheets_v4 } from '@googleapis/sheets'; @@ -13,8 +13,8 @@ import got from 'got'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; -import { getRundown } from '../rundown-service/rundownUtils.js'; +import { parseRundowns } from '../../utils/parserFunctions.js'; +import { getCurrentRundown, getRundownOrThrow } from '../rundown-service/rundownUtils.js'; import { getCustomFields } from '../rundown-service/rundownCache.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js'; @@ -292,8 +292,8 @@ export async function upload(sheetId: string, options: ImportMap) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } - const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), options); - const rundown = getRundown(); + const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options); + const rundown = getCurrentRundown(); const titleRow = Object.values(rundownMetadata)[0]['row']; const updateRundown = Array(); @@ -322,16 +322,17 @@ export async function upload(sheetId: string, options: ImportMap) { range: { dimension: 'ROWS', startIndex: titleRow + 1, - endIndex: titleRow + rundown.length, + endIndex: titleRow + rundown.order.length, sheetId: worksheetId, }, }, }); // update the corresponding row with event data - rundown.forEach((entry, index) => - updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)), - ); + rundown.order.forEach((entryId, index) => { + const entry = rundown.entries[entryId]; + return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)); + }); const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ spreadsheetId: sheetId, @@ -353,7 +354,7 @@ export async function download( sheetId: string, options: ImportMap, ): Promise<{ - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; }> { const { range } = await verifyWorksheet(sheetId, options.worksheet); @@ -369,10 +370,19 @@ export async function download( throw new Error(`Sheet read failed: ${googleResponse.statusText}`); } - const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), options); - const { customFields, rundown } = parseRundown(dataFromSheet); - if (rundown.length < 1) { + const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options); + + const rundownId = dataFromSheet.rundown.id; + const dataModel: Pick = { + rundowns: { + [rundownId]: dataFromSheet.rundown, + }, + customFields: dataFromSheet.customFields, + }; + const { customFields, rundowns } = parseRundowns(dataModel); + const rundown = getRundownOrThrow(rundowns, rundownId); + if (rundown.order.length < 1) { throw new Error('Sheet: Could not find data to import in the worksheet'); } - return { rundown, customFields }; + return { rundown: rundowns[rundownId], customFields }; } diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 4f7861ecf4..fbb7e26995 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -39,6 +39,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, + currentBlock: null, revision: 0, id: '1358', timeWarning: 0, @@ -84,6 +85,7 @@ describe('cellRequestFromEvent()', () => { isPublic: false, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -134,6 +136,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -186,6 +189,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, + currentBlock: null, revision: 0, id: '1358', timeWarning: 0, @@ -218,6 +222,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -254,6 +259,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, diff --git a/apps/server/src/services/sheet-service/sheetUtils.ts b/apps/server/src/services/sheet-service/sheetUtils.ts index 8e7742f74e..e796318a57 100644 --- a/apps/server/src/services/sheet-service/sheetUtils.ts +++ b/apps/server/src/services/sheet-service/sheetUtils.ts @@ -1,4 +1,4 @@ -import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeEntry } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import type { sheets_v4 } from '@googleapis/sheets'; @@ -74,14 +74,14 @@ export function getA1Notation(row: number, column: number): string { /** * @description - creates updateCells request from ontime event - * @param {OntimeRundownEntry} event + * @param {OntimeEntry} event * @param {number} index - index of the event * @param {number} worksheetId * @param {object} metadata - object with all the cell positions of the title of each attribute * @returns {sheets_v4.Schema} - list of update requests */ export function cellRequestFromEvent( - event: OntimeRundownEntry, + event: OntimeEntry, index: number, worksheetId: number, metadata: object, @@ -125,7 +125,7 @@ export function cellRequestFromEvent( }; } -function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeRundownEntry) { +function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) { if (isOntimeEvent(event)) { if (key === 'blank') { return {}; diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index b385e6588d..1c056cd53e 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,5 +1,11 @@ -import { OntimeRundown, PlayableEvent, Playback, SupportedEvent, TimerPhase } from 'ontime-types'; -import { deepmerge } from 'ontime-utils'; +import { PlayableEvent, Playback, TimerPhase } from 'ontime-types'; + +import { initRundown } from '../../services/rundown-service/RundownService.js'; +import { + makeOntimeBlock, + makeOntimeEvent, + makeRundown, +} from '../../services/rundown-service/__mocks__/rundown.mocks.js'; import { type RuntimeState, @@ -13,7 +19,6 @@ import { start, stop, } from '../runtimeState.js'; -import { initRundown } from '../../services/rundown-service/RundownService.js'; const mockEvent = { type: 'event', @@ -23,6 +28,7 @@ const mockEvent = { timeEnd: 1000, duration: 1000, skip: false, + currentBlock: null, } as PlayableEvent; const mockState = { @@ -51,11 +57,6 @@ const mockState = { }, } as RuntimeState; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const makeMockState = (patch: RuntimeState): RuntimeState => { - return deepmerge(mockState, patch); -}; - beforeAll(() => { vi.mock('../../classes/data-provider/DataProvider.js', () => { return { @@ -70,23 +71,14 @@ beforeAll(() => { }); describe('mutation on runtimeState', () => { - beforeEach(() => { + beforeEach(async () => { clearState(); - vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => { const actual = (await importOriginal()) as object; return { ...actual, - getPlayableEvents: vi.fn().mockReturnValue([ - { - id: 'mock', - cue: 'mock', - timeStart: 0, - timeEnd: 1000, - duration: 1000, - }, - ]), + initRunddown: vi.fn().mockReturnValue(undefined), }; }); }); @@ -97,15 +89,18 @@ describe('mutation on runtimeState', () => { describe('playback operations', async () => { it('refuses if nothing is loaded', () => { + initRundown(makeRundown({}), {}); let success = start(mockState); expect(success).toBe(false); success = pause(); expect(success).toBe(false); }); + test('normal playback cycle', () => { // 1. Load event - load(mockEvent, [mockEvent]); + const mockRundown = makeRundown({ entries: { [mockEvent.id]: mockEvent }, order: [mockEvent.id] }); + load(mockEvent, mockRundown, mockRundown.order); let newState = getState(); expect(newState.eventNow?.id).toBe(mockEvent.id); expect(newState.timer.playback).toBe(Playback.Armed); @@ -170,17 +165,21 @@ describe('mutation on runtimeState', () => { }); // do this before the test so that it is applied - const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 }; - const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 }; + const entries = { + event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, currentBlock: null }, + event2: { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500, currentBlock: null }, + }; + const rundown = makeRundown({ entries, order: ['event1', 'event2'] }); + // force update vi.useFakeTimers(); - await initRundown([event1, event2], {}); + await initRundown(rundown, {}); vi.runAllTimers(); vi.useRealTimers(); test('runtime offset', async () => { // 1. Load event - load(event1, [event1, event2]); + load(entries.event1, rundown, rundown.order); let newState = getState(); expect(newState.runtime.actualStart).toBeNull(); expect(newState.runtime.plannedStart).toBe(0); @@ -197,11 +196,11 @@ describe('mutation on runtimeState', () => { } expect(newState.runtime.actualStart).toBe(newState.clock); - expect(newState.runtime.offset).toBe(event1.timeStart - newState.clock); - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.offset).toBe(entries.event1.timeStart - newState.clock); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); // 3. Next event - load(event2, [event1, event2]); + load(entries.event2, rundown, rundown.order); start(); newState = getState(); @@ -214,10 +213,10 @@ describe('mutation on runtimeState', () => { const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart); expect(forgivingActualStart).toBeLessThanOrEqual(1); // we are over-under, the difference between the schedule and the actual start - const delayBefore = event2.timeStart - newState.clock; + const delayBefore = entries.event2.timeStart - newState.clock; expect(newState.runtime.offset).toBe(delayBefore); // finish is the difference between the runtime and the schedule - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); expect(newState.currentBlock.block).toBeNull(); // 4. Add time @@ -228,7 +227,7 @@ describe('mutation on runtimeState', () => { } expect(newState.runtime.offset).toBe(delayBefore - 10); - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); // 5. Stop event stop(); @@ -237,8 +236,6 @@ describe('mutation on runtimeState', () => { expect(newState.runtime.offset).toBe(0); expect(newState.runtime.expectedEnd).toBeNull(); }); - - test.todo('runtime offset on timers in overtime', () => {}); }); }); @@ -253,14 +250,17 @@ describe('roll mode', () => { }); describe('normal roll', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); test('pending event', () => { - const { eventId, didStart } = roll(rundown); + const { eventId, didStart } = roll(rundown, rundown.order); const state = getState(); expect(eventId).toBe('1'); @@ -271,29 +271,32 @@ describe('roll mode', () => { test('roll events', () => { vi.setSystemTime('jan 1 00:00:01'); - let result = roll(rundown); + let result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '1', didStart: true }); vi.setSystemTime('jan 1 00:00:02'); - result = roll(rundown); + result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '2', didStart: true }); vi.setSystemTime('jan 1 00:00:03:500'); - result = roll(rundown); + result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '3', didStart: true }); }); }); describe('roll takover', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); test('from load', () => { - load(rundown[2], rundown); - const result = roll(rundown); + load(rundown.entries[3] as PlayableEvent, rundown, rundown.order); + const result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '3', didStart: false }); const state = getState(); expect(state.timer.phase).toBe(TimerPhase.Pending); @@ -301,9 +304,9 @@ describe('roll mode', () => { }); test('from play', () => { - load(rundown[0], rundown); + load(rundown.entries[1] as PlayableEvent, rundown, rundown.order); start(); - const result = roll(rundown); + const result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(getState().runtime.offset).toBe(1000); }); @@ -311,153 +314,167 @@ describe('roll mode', () => { describe('roll continue with offset', () => { test('no gaps', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); - load(rundown[0], rundown); + load(rundown.entries[1] as PlayableEvent, rundown, rundown.order); start(); - let result = roll(rundown, getState().runtime.offset); + let result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(getState().runtime.offset).toBe(1000); vi.setSystemTime('jan 1 00:00:01'); - result = roll(rundown, getState().runtime.offset); + result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '2', didStart: true }); expect(getState().runtime.offset).toBe(1000); vi.setSystemTime('jan 1 00:00:02'); - result = roll(rundown, getState().runtime.offset); + result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '3', didStart: true }); expect(getState().runtime.offset).toBe(1000); }); - - test.todo('with gaps', () => { - //this is a bit involved as it also depends somewhat on the RintimeService - }); }); }); describe('loadBlock', () => { test('from no-block to a block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: [] }), + 3: makeOntimeEvent({ id: '3', currentBlock: null }), + }, + order: ['0', '1', '2', '3'], + }); const state = { currentBlock: { block: null, startedAt: 123, }, - eventNow: rundown[2], + eventNow: rundown.entries[11], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[1], startedAt: null }, - eventNow: rundown[2], + currentBlock: { block: rundown.entries[1], startedAt: null }, + eventNow: rundown.entries[11], }); }); test('from block to a different block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: ['22'] }), + 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + }, + order: ['0', '1', '2'], + }); const state = { currentBlock: { - block: rundown[1], + block: rundown.entries[1], startedAt: 123, }, - eventNow: rundown[4], + eventNow: rundown.entries[22], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[3], startedAt: null }, - eventNow: rundown[4], + currentBlock: { block: rundown.entries[2], startedAt: null }, + eventNow: rundown.entries[22], }); }); test('from block to a no-block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: ['22'] }), + 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + }, + order: ['0', '1', '2'], + }); const state = { currentBlock: { - block: rundown[1], + block: rundown.entries[1], startedAt: 123, }, - eventNow: rundown[0], + eventNow: rundown.entries[0], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ currentBlock: { block: null, startedAt: null }, - eventNow: rundown[0], + eventNow: rundown.entries[0], }); }); test('from block to same block will keep startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Block }, - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeBlock({ id: '0', events: ['1', '2'] }), + 1: makeOntimeEvent({ id: '1', currentBlock: '0' }), + 2: makeOntimeEvent({ id: '2', currentBlock: '0' }), + }, + order: ['0'], + }); const state = { currentBlock: { - block: rundown[0], + block: rundown.entries[0], startedAt: 123, }, - eventNow: rundown[2], + eventNow: rundown.entries[2], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[0], startedAt: 123 }, - eventNow: rundown[2], + currentBlock: { block: rundown.entries[0], startedAt: 123 }, + eventNow: rundown.entries[2], }); }); test('from no-block to no-block will keep startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeEvent({ id: '1', currentBlock: null }), + }, + order: ['0', '1'], + }); const state = { currentBlock: { block: null, startedAt: 123, }, - eventNow: rundown[0], + eventNow: rundown.entries[0], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ currentBlock: { block: null, startedAt: 123 }, - eventNow: rundown[0], + eventNow: rundown.entries[0], }); }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 1eaacd8d8f..a095a71a99 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,26 +1,20 @@ import { CurrentBlockState, + EntryId, isPlayableEvent, MaybeNumber, MaybeString, - OffsetMode, + OntimeBlock, OntimeEvent, - OntimeRundown, PlayableEvent, Playback, + Rundown, Runtime, runtimeStorePlaceholder, TimerPhase, TimerState, } from 'ontime-types'; -import { - calculateDuration, - checkIsNow, - dayInMs, - filterTimedEvents, - getPreviousBlock, - isPlaybackActive, -} from 'ontime-utils'; +import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/RestoreService.js'; @@ -33,6 +27,7 @@ import { } from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; +import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js'; export type RuntimeState = { clock: number; // realtime clock @@ -183,19 +178,23 @@ export function updateRundownData(rundownData: RundownData) { */ export function load( event: PlayableEvent, - rundown: OntimeRundown, + rundown: Rundown, + timedEventsOrder: EntryId[], initialData?: Partial, ): boolean { clearEventData(); - // filter rundown - const timedEvents = filterTimedEvents(rundown); - const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id); + if (timedEventsOrder.length === 0 || !isPlayableEvent(event)) { + return false; + } - if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) { + // filter rundown + const eventIndex = timedEventsOrder.findIndex((timedEventId) => timedEventId === event.id); + if (eventIndex === -1) { return false; } + const timedEvents = filterTimedEvents(rundown, timedEventsOrder); // load events in memory along with their data loadNow(timedEvents, eventIndex); loadNext(timedEvents, eventIndex); @@ -312,8 +311,8 @@ export function loadNext( /** * Resume from restore point */ -export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) { - load(event, rundown, restorePoint); +export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: Rundown, timedEventOrder: EntryId[]) { + load(event, rundown, timedEventOrder, restorePoint); } /** @@ -373,8 +372,8 @@ export function updateLoaded(event?: PlayableEvent): string | undefined { /** * Used in situations when we want to hot-reload all events without interrupting timer */ -export function updateAll(rundown: OntimeRundown) { - const timedEvents = filterTimedEvents(rundown); +export function updateAll(rundown: Rundown, timedEventsOrder: EntryId[]) { + const timedEvents = filterTimedEvents(rundown, timedEventsOrder); loadNow(timedEvents); loadNext(timedEvents); updateLoaded(runtimeState.eventNow ?? undefined); @@ -388,6 +387,7 @@ export function start(state: RuntimeState = runtimeState): boolean { if (state.timer.playback === Playback.Play) { return false; } + state.clock = timeNow(); state.timer.secondaryTimer = null; @@ -576,7 +576,11 @@ export function update(): UpdateResult { } } -export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString; didStart: boolean } { +export function roll( + rundown: Rundown, + timedEventOrder: EntryId[], + offset = 0, +): { eventId: MaybeString; didStart: boolean } { // 1. if an event is running, we simply take over the playback if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { runtimeState.timer.playback = Playback.Roll; @@ -633,7 +637,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString } // 3. if there is no event running, we need to find the next event - const timedEvents = filterTimedEvents(rundown); + const timedEvents = filterTimedEvents(rundown, timedEventOrder); if (timedEvents.length === 0) { throw new Error('No playable events found'); } @@ -709,9 +713,8 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString /** * handle block loading, not for use outside of runtimeState - * @param rundown */ -export function loadBlock(rundown: OntimeRundown, state = runtimeState) { +export function loadBlock(rundown: Rundown, state = runtimeState) { if (state.eventNow === null) { // we need a loaded event to have a block state.currentBlock.block = null; @@ -719,17 +722,19 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) { return; } - const newCurrentBlock = getPreviousBlock(rundown, state.eventNow.id); + const currentBlockId = state.eventNow.currentBlock; // update time only if the block has changed - if (state.currentBlock.block?.id !== newCurrentBlock?.id) { + if (state.currentBlock.block?.id != currentBlockId) { state.currentBlock.startedAt = null; } // update the block anyway - state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock }; -} + if (currentBlockId === null) { + state.currentBlock.block = null; + return; + } -export function setOffsetMode(mode: OffsetMode) { - runtimeState.runtime.offsetMode = mode; + const currentBlock = rundown.entries[currentBlockId]; + state.currentBlock.block = currentBlock as OntimeBlock; } diff --git a/apps/server/src/utils/__tests__/parser.mock-data.ts b/apps/server/src/utils/__tests__/parser.mock-data.ts new file mode 100644 index 0000000000..5de92840a5 --- /dev/null +++ b/apps/server/src/utils/__tests__/parser.mock-data.ts @@ -0,0 +1,59 @@ +export const dataFromExcelTemplate = [ + ['Ontime ┬À Schedule Template'], + [], + [ + 'id', + 'Time Start', + 'Time End', + 'Title', + 'End Action', + 'Timer type', + 'Count to end', + 'Public', + 'Skip', + 'Notes', + 't0', + 'Test1', + 'test2', + 'test3', + 'Colour', + 'cue', + ], + [ + 'event-a', // <-- eventId + '07:00:00', // <-- timeStart + '08:00:10', // <-- timeEnd + 'Guest Welcome', // <-- title + '', // <-- endAction + '', // <-- timerType + 'x', // <-- count to end + 'x', // <-- public + '', // <-- skip + 'Ballyhoo', // <-- notes + 'a0', // <-- t0 + 'a1', // <-- test1 + 'a2', // <-- test2 + 'a3', // <-- test3 + 'red', // <-- colour + 101, // <-- cue + ], + [ + 'event-b', // <-- eventId + '08:00:00', // <-- timeStart + '08:30:00', // <-- timeEnd + 'A song from the hearth', // <-- title + 'load-next', // <-- endAction + 'clock', // timerType + 'x', // <-- count to end + '', // <-- public + 'x', // <-- skip + 'Rainbow chase', // <-- notes + 'b0', // <-- t0 + '', // <-- test1 + '', // <-- test2 + '', // <-- test3 + '#F00', // <-- colour + 102, // <-- cue + ], + [], +]; diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 3f34bb90da..8b6d77973d 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -1,32 +1,17 @@ /* eslint-disable no-console -- we are mocking the console */ import { assertType, vi } from 'vitest'; -import { - CustomFields, - DatabaseModel, - EndAction, - OntimeEvent, - OntimeRundown, - ProjectData, - Settings, - SupportedEvent, - TimerType, - TimeStrategy, - ViewSettings, -} from 'ontime-types'; +import { CustomFields, DatabaseModel, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; +import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; import { dbModel } from '../../models/dataModel.js'; +import { demoDb } from '../../models/demoProject.js'; import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js'; import { makeString } from '../parserUtils.js'; -import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js'; -import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; -import * as cache from '../../services/rundown-service/rundownCache.js'; +import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js'; -const requiredSettings = { - app: 'ontime', - version: 'any', -}; +import { dataFromExcelTemplate } from './parser.mock-data.js'; // mock data provider beforeAll(() => { @@ -42,302 +27,24 @@ beforeAll(() => { }); }); -describe('test json parser with valid def', () => { - const testData: Partial = { - rundown: [ - { - id: '4b31', - cue: 'Guest Welcoming', - type: SupportedEvent.Event, - title: 'Guest Welcoming', - note: '', - endAction: EndAction.PlayNext, - timerType: TimerType.Clock, - timeStart: 31500000, - timeEnd: 32400000, - duration: 32400000 - 31500000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: 'f24d', - cue: 'Good Morning', - type: SupportedEvent.Event, - title: 'Good Morning', - note: '', - endAction: EndAction.PlayNext, - timerType: TimerType.CountUp, - timeStart: 32400000, - timeEnd: 36000000, - duration: 36000000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: true, - skip: true, - colour: 'red', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: 'bbc5', - cue: 'Stage 2 setup', - type: SupportedEvent.Event, - title: 'Stage 2 setup', - note: '', - endAction: 'wrong action' as EndAction, // testing - timerType: TimerType.Clock, - timeStart: 32400000, - timeEnd: 37200000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - // testing incomplete dataset - id: '5b3e', - cue: 'Working Procedures', - type: SupportedEvent.Event, - title: 'Working Procedures', - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 37200000, - timeEnd: 39000000, - duration: 39000000 - 37200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - } as OntimeEvent, - { - id: '8e2c', - cue: 'Lunch', - title: 'Lunch', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 39600000, - timeEnd: 45000000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: '08e9', - cue: 'A day being carlos', - title: 'A day being carlos', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 46800000, - timeEnd: 50400000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: true, - skip: true, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - // testing incomplete dataset - id: 'e25a', - cue: 'Hamburgers and Cheese', - title: 'Hamburgers and Cheese', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 54000000, - timeEnd: 57600000, - duration: 37200000 - 32400000, - isPublic: true, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - } as OntimeEvent, - ] as OntimeRundown, - project: { - title: 'This is a test definition', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - } as ProjectData, - settings: { - app: 'ontime', - version: '2.0.0', - timeFormat: '24', - } as Settings, - viewSettings: {} as ViewSettings, - }; - - const { data } = parseDatabaseModel(testData); - - it('has 7 events', () => { - const length = data.rundown.length; - expect(length).toBe(7); - }); - - it('first event is as a match', () => { - const first = data.rundown[0]; - const expected = { - title: 'Guest Welcoming', - type: 'event', - id: '4b31', - }; - expect(first).toMatchObject(expected); - }); - - it('second event is as a match', () => { - const second = data.rundown[1]; - const expected = { - title: 'Good Morning', - type: 'event', - id: 'f24d', - }; - expect(second).toMatchObject(expected); - }); - it('third event end action is set as the default value', () => { - const third = data.rundown[2]; - expect((third as OntimeEvent).endAction).toStrictEqual(EndAction.None); - }); - it('fourth event timer type is set as the default value', () => { - const fourth = data.rundown[3]; - expect((fourth as OntimeEvent).timerType).toStrictEqual(TimerType.Clock); - }); +describe('test parseDatabaseModel() with demo project (valid)', () => { + const filteredDemoProject = structuredClone(demoDb); + const { data } = parseDatabaseModel(filteredDemoProject); - it('loaded event settings', () => { - const eventTitle = data.project.title; - expect(eventTitle).toBe('This is a test definition'); - }); + delete filteredDemoProject.settings.version; + delete data.settings.version; - it('endMessage to exist but be empty', () => { - const endMessage = data.viewSettings.endMessage; - expect(endMessage).toBeDefined(); - expect(endMessage).toBe(''); + it('has 16 events', () => { + expect(data.rundowns.demo.order.length).toBe(16); + expect(Object.keys(data.rundowns.demo.entries).length).toBe(16); }); - it('settings are for right app and version', () => { - const settings = data.settings; - expect(settings.app).toBe('ontime'); - expect(settings.version).toEqual(expect.any(String)); + it('is the same as the demo project since all data is valid', () => { + expect(data).toMatchObject(filteredDemoProject); }); }); -describe('test parser edge cases', () => { - it('stringifies necessary values', () => { - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - cue: 101, - type: 'event', - }, - { - cue: 101.1, - type: 'event', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(typeof (data.rundown[0] as OntimeEvent).cue).toBe('string'); - expect(typeof (data.rundown[1] as OntimeEvent).cue).toBe('string'); - }); - - it('generates missing ids', () => { - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event', - type: 'event', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(data.rundown[0].id).toBeDefined(); - }); - - it('detects duplicate Ids', () => { - console.log = vi.fn(); - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event 1', - type: 'event', - id: '1', - }, - { - title: 'Test Event 2', - type: 'event', - id: '1', - }, - ], - }; - - //@ts-expect-error -- we know this is wrong, testing imports outside domain - const { data, errors } = parseDatabaseModel(testData); - expect(data.rundown.length).toBe(1); - expect(errors.length).toBe(5); - }); - - it('handles incomplete datasets', () => { - console.log = vi.fn(); - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event 1', - id: '1', - }, - { - title: 'Test Event 2', - id: '1', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(data.rundown.length).toBe(0); - }); - +describe('test parseDatabaseModel() edge cases', () => { it('skips unknown app and version settings', () => { console.log = vi.fn(); const testData = { @@ -349,108 +56,6 @@ describe('test parser edge cases', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain expect(() => parseDatabaseModel(testData)).toThrow(); }); -}); - -describe('test corrupt data', () => { - it('handles some empty events', () => { - const emptyEvents = { - rundown: [ - {}, - {}, - {}, - { - title: 'Test Event 1', - type: 'event', - id: '1', - }, - { - title: 'Test Event 2', - type: 'event', - id: '2', - }, - {}, - {}, - {}, - ], - project: { - title: 'All about Carlos demo event', - description: 'description', - publicUrl: 'www.carlosvalente.com', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - endMessage: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(emptyEvents); - expect(data.rundown.length).toBe(2); - }); - - it('handles all empty events', () => { - const emptyEvents = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - project: { - title: 'All about Carlos demo event', - description: 'description', - publicUrl: 'www.carlosvalente.com', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - endMessage: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(emptyEvents); - expect(data.rundown.length).toBe(0); - }); - - it('handles missing project data', () => { - const emptyProjectData = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - project: {}, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - lock: null, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data: parsedDef } = parseDatabaseModel(emptyProjectData); - expect(parsedDef.project).toStrictEqual(dbModel.project); - }); - - it('handles missing settings', () => { - const missingSettings = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - event: {}, - settings: { - app: 'ontime', - version: '2.0.0', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(missingSettings); - expect(data.settings).toStrictEqual(dbModel.settings); - }); it('fails with invalid JSON', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain @@ -608,125 +213,6 @@ describe('test views import', () => { }); }); -describe('test import of v2 datamodel', () => { - it('ignores deprecated fields and generates new ones', () => { - const v2ProjectFile = { - rundown: [ - { type: SupportedEvent.Block, title: 'block-title', id: 'block-id' }, - { type: SupportedEvent.Delay, duration: 0 }, - { type: SupportedEvent.Event, title: 'event-title', id: 'event-id' }, - ], - project: { - title: '', - description: '', - publicUrl: '', - publicInfo: '', - backstageUrl: '', - backstageInfo: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - editorKey: null, - operatorKey: null, - timeFormat: '24', - language: 'en', - }, - viewSettings: { - overrideStyles: false, - normalColor: '#ffffffcc', - warningColor: '#FFAB33', - dangerColor: '#ED3333', - endMessage: '', - }, - aliases: [], - userFields: { - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', - }, - osc: { - portIn: 8888, - portOut: 9999, - targetIP: '127.0.0.1', - enabledIn: false, - enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - onWarning: [], - onDanger: [], - }, - }, - http: { - enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, - }, - }; - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data: parsed, _errors } = parseDatabaseModel(v2ProjectFile); - expect(parsed.rundown.length).toBe(3); - expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block }); - expect(parsed.rundown[0]).toEqual( - expect.objectContaining({ - id: expect.any(String), - title: expect.any(String), - }), - ); - expect(parsed.rundown[1]).toMatchObject({ type: SupportedEvent.Delay }); - expect(parsed.rundown[1]).toEqual( - expect.objectContaining({ - id: expect.any(String), - duration: expect.any(Number), - }), - ); - expect(parsed.rundown[2]).toMatchObject({ type: SupportedEvent.Event }); - expect(parsed.rundown[2]).toEqual( - expect.objectContaining({ - id: expect.any(String), - cue: expect.any(String), - title: expect.any(String), - note: expect.any(String), - endAction: expect.any(String), - timerType: expect.any(String), - linkStart: null, - timeStrategy: expect.any(String), - timeStart: expect.any(Number), - timeEnd: expect.any(Number), - duration: expect.any(Number), - isPublic: expect.any(Boolean), - skip: expect.any(Boolean), - colour: expect.any(String), - revision: expect.any(Number), - timeWarning: expect.any(Number), - timeDanger: expect.any(Number), - custom: expect.any(Object), - }), - ); - // @ts-expect-error -- checking if the field is removed - expect(parsed?.userFields).toBeUndefined(); - }); -}); - describe('makeString()', () => { it('converts variables to string', () => { const cases = [ @@ -885,282 +371,87 @@ describe('getCustomFieldData()', () => { describe('parseExcel()', () => { it('parses the example file', () => { - const testdata = [ - ['Ontime ┬À Schedule Template'], - [], - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Count to end', - 'Public', - 'Skip', - 'Notes', - 't0', - 'Test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '07:00:00', - '08:00:10', - 'Guest Welcome', - '', - '', - 'x', // <-- count to end - 'x', // <-- public - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '08:00:00', - '08:30:00', - 'A song from the hearth', - 'load-next', - 'clock', - 'x', // <-- count to end - '', // <-- public - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; - // partial import map with only custom fields const importMap = { custom: { user0: 't0', - User1: 'Test1', + user1: 'Test1', user2: 'test2', user3: 'test3', - user4: 'test4', - user5: 'test5', - user6: 'test6', - user7: 'test7', - user8: 'test8', - user9: 'test9', }, }; - // TODO: update tests once import is resolved - const expectedParsedRundown = [ - { - timeStart: 25200000, - timeEnd: 28810000, + const existingCustomFields: CustomFields = { + user0: { type: 'string', colour: 'red', label: 'user0' }, + user1: { type: 'string', colour: 'green', label: 'user1' }, + user2: { type: 'string', colour: 'blue', label: 'user2' }, + }; + + const parsedData = parseExcel(dataFromExcelTemplate, existingCustomFields, 'testSheet', importMap); + expect(parsedData.customFields).toStrictEqual({ + user0: { + type: 'string', + colour: 'red', + label: 'user0', + }, + user1: { + type: 'string', + colour: 'green', + label: 'user1', + }, + user2: { + type: 'string', + colour: 'blue', + label: 'user2', + }, + user3: { + type: 'string', + colour: '', + label: 'user3', + }, + }); + expect(parsedData.rundown.order.length).toBe(2); + // TODO: why dont we parse the date in UTC? + expect(parsedData.rundown.entries).toMatchObject({ + 'event-a': { + id: 'event-a', + //timeStart: 28800000, + //timeEnd: 32410000, title: 'Guest Welcome', timerType: 'count-down', endAction: 'none', isPublic: true, - countToEnd: true, skip: false, note: 'Ballyhoo', custom: { user0: 'a0', - User1: 'a1', + user1: 'a1', user2: 'a2', user3: 'a3', - user4: 'a4', - user5: 'a5', - user6: 'a6', - user7: 'a7', - user8: 'a8', - user9: 'a9', }, colour: 'red', type: 'event', cue: '101', }, - { - timeStart: 28800000, - timeEnd: 30600000, + 'event-b': { + id: 'event-b', + //timeStart: 32400000, + //timeEnd: 34200000, title: 'A song from the hearth', timerType: 'clock', - countToEnd: true, endAction: 'load-next', isPublic: false, skip: true, note: 'Rainbow chase', - custom: { - user0: 'b0', - user5: 'b5', - }, + custom: {}, colour: '#F00', type: 'event', cue: '102', }, - ]; - - const existingCustomFields: CustomFields = { - user0: { type: 'string', colour: 'red', label: 'user0' }, - User1: { type: 'string', colour: 'green', label: 'user1' }, - user2: { type: 'string', colour: 'blue', label: 'user2' }, - }; - - const { customFields, rundown } = parseExcel(testdata, existingCustomFields, importMap); - expect(customFields).toStrictEqual({ - user0: { - type: 'string', - colour: 'red', - label: 'user0', - }, - User1: { - type: 'string', - colour: 'green', - label: 'User1', - }, - user2: { - type: 'string', - colour: 'blue', - label: 'user2', - }, - user3: { - type: 'string', - colour: '', - label: 'user3', - }, - user4: { - type: 'string', - colour: '', - label: 'user4', - }, - user5: { - type: 'string', - colour: '', - label: 'user5', - }, - user6: { - type: 'string', - colour: '', - label: 'user6', - }, - user7: { - type: 'string', - colour: '', - label: 'user7', - }, - user8: { - type: 'string', - colour: '', - label: 'user8', - }, - user9: { - type: 'string', - colour: '', - label: 'user9', - }, - }); - expect(rundown.length).toBe(2); - expect(rundown[0]).toMatchObject(expectedParsedRundown[0]); - expect(rundown[1]).toMatchObject(expectedParsedRundown[1]); - }); + }); + }); it('parses a file without custom fields', () => { - const testdata = [ - ['Ontime ┬À Schedule Template'], - [], - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '1899-12-30T07:00:00.000Z', - '1899-12-30T08:00:10.000Z', - 'Guest Welcome', - '', - '', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; - // partial import map with only custom fields const importMap = { custom: { @@ -1169,39 +460,7 @@ describe('parseExcel()', () => { }, }; - // TODO: update tests once import is resolved - const expectedParsedRundown = [ - { - //timeStart: 28800000, - //timeEnd: 32410000, - title: 'Guest Welcome', - timerType: 'count-down', - endAction: 'none', - isPublic: true, - skip: false, - note: 'Ballyhoo', - custom: {}, - colour: 'red', - type: 'event', - cue: '101', - }, - { - //timeStart: 32400000, - //timeEnd: 34200000, - title: 'A song from the hearth', - timerType: 'clock', - endAction: 'load-next', - isPublic: false, - skip: true, - note: 'Rainbow chase', - custom: {}, - colour: '#F00', - type: 'event', - cue: '102', - }, - ]; - - const parsedData = parseExcel(testdata, {}, importMap); + const parsedData = parseExcel(dataFromExcelTemplate, {}, 'testSheet', importMap); expect(parsedData.customFields).toStrictEqual({ niu1: { type: 'string', @@ -1214,270 +473,93 @@ describe('parseExcel()', () => { label: 'niu2', }, }); - expect(parsedData.rundown.length).toBe(2); - expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]); - expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]); + expect(parsedData.rundown.order).toMatchObject(['event-a', 'event-b']); + expect(parsedData.rundown.entries['event-a']).toMatchObject({ + //timeStart: 28800000, + //timeEnd: 32410000, + id: 'event-a', + title: 'Guest Welcome', + timerType: 'count-down', + endAction: 'none', + isPublic: true, + skip: false, + note: 'Ballyhoo', + custom: {}, + colour: 'red', + type: 'event', + cue: '101', + }); + expect(parsedData.rundown.entries['event-b']).toMatchObject({ + //timeStart: 32400000, + //timeEnd: 34200000, + id: 'event-b', + title: 'A song from the hearth', + timerType: 'clock', + endAction: 'load-next', + isPublic: false, + skip: true, + note: 'Rainbow chase', + custom: {}, + colour: '#F00', + type: 'event', + cue: '102', + }); }); it('ignores unknown event types', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '1899-12-30T07:00:00.000Z', - '1899-12-30T08:00:10.000Z', - 'Guest Welcome', - '', - 'skip', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], + ['Title', 'Timer type'], + ['Guest Welcome', 'x'], + ['A song from the hearth', 'clock'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(1); - expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth'); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + + expect(result.rundown.order.length).toBe(1); + expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth'); }); it('imports blocks', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '', - '', - '', - '', - 'block', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], + ['Title', 'Timer type'], + ['a block', 'block'], + ['an event', 'clock'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(2); - expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Block); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + + expect(result.rundown.order.length).toBe(2); + expect((firstEvent as OntimeEvent).type).toBe(SupportedEvent.Block); }); it('imports as events if there is no timer type column', () => { - const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - ['', '', '', '', 'x', '', 'Ballyhoo', 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'red', 101], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; + const testdata = [['Title'], ['no timer type'], ['also no timer type']]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(2); - expect(result.rundown[0]).toMatchObject({ + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const secondEvent = result.rundown.entries[result.rundown.order[1]]; + + expect(result.rundown.order.length).toBe(2); + expect(firstEvent).toMatchObject({ type: SupportedEvent.Event, timerType: TimerType.CountDown, }); - expect(result.rundown[1]).toMatchObject({ + + expect(secondEvent).toMatchObject({ type: SupportedEvent.Event, timerType: TimerType.CountDown, }); @@ -1485,289 +567,142 @@ describe('parseExcel()', () => { it('imports as events if timer type is empty or has whitespace', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - 'Timer type', - ], - [ - '', - '', - '', - '', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ' ', - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - undefined, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 103, - ' count-up ', - ], - [], + ['Title', 'Timer type'], + ['first', ' '], + ['second', undefined], + ['third', ' count-up '], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(3); - expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown); - expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown); - expect((result.rundown.at(2) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(2) as OntimeEvent).timerType).toBe(TimerType.CountUp); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const secondEvent = result.rundown.entries[result.rundown.order[1]]; + const thirdEvent = result.rundown.entries[result.rundown.order[2]]; + expect(result.rundown.order.length).toBe(3); + expect(firstEvent).toMatchObject({ title: 'first', type: SupportedEvent.Event, timerType: TimerType.CountDown }); + expect(secondEvent).toMatchObject({ title: 'second', type: SupportedEvent.Event, timerType: TimerType.CountDown }); + expect(thirdEvent).toMatchObject({ title: 'third', type: SupportedEvent.Event, timerType: TimerType.CountUp }); }); it('am/pm conversion to 24h', () => { const testData = [ - ['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'], - ['4:30:00', '4:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00', '10:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['16:30:00', '16:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['21:45:00', '22:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['4:30:00AM', '4:36:00AM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00AM', '10:56:00AM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['4:30:00PM', '4:36:00PM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00PM', '10:56:00PM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - [], + ['Time Start', 'Time End', 'ID'], + ['4:30:00', '4:36:00', 'event-1'], + ['9:45:00', '10:56:00', 'event-2'], + ['16:30:00', '16:36:00', 'event-3'], + ['21:45:00', '22:56:00', 'event-4'], + ['4:30:00AM', '4:36:00AM', 'event-5'], + ['9:45:00AM', '10:56:00AM', 'event-6'], + ['4:30:00PM', '4:36:00PM', 'event-7'], + ['9:45:00PM', '10:56:00PM', 'event-8'], ]; const importMap = { - worksheet: 'event schedule', timeStart: 'time start', timeEnd: 'time end', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, + id: 'id', }; - const result = parseExcel(testData, {}, importMap); - const { rundown } = parseRundown(result); - const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[]; - expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); - expect((events.at(1) as OntimeEvent).timeStart).toEqual(35100000); - expect((events.at(2) as OntimeEvent).timeStart).toEqual(59400000); - expect((events.at(3) as OntimeEvent).timeStart).toEqual(78300000); - expect((events.at(4) as OntimeEvent).timeStart).toEqual(16200000); - expect((events.at(5) as OntimeEvent).timeStart).toEqual(35100000); - expect((events.at(6) as OntimeEvent).timeStart).toEqual(59400000); - expect((events.at(7) as OntimeEvent).timeStart).toEqual(78300000); + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(8); + expect(result.rundown.entries['event-1']).toMatchObject({ + timeStart: 16200000, + timeEnd: 16560000, + }); + expect(result.rundown.entries['event-2']).toMatchObject({ + timeStart: 35100000, + timeEnd: 39360000, + }); + expect(result.rundown.entries['event-3']).toMatchObject({ + timeStart: 59400000, + timeEnd: 59760000, + }); + expect(result.rundown.entries['event-4']).toMatchObject({ + timeStart: 78300000, + timeEnd: 82560000, + }); + expect(result.rundown.entries['event-5']).toMatchObject({ + timeStart: 16200000, + timeEnd: 16560000, + }); + expect(result.rundown.entries['event-6']).toMatchObject({ + timeStart: 35100000, + timeEnd: 39360000, + }); + expect(result.rundown.entries['event-7']).toMatchObject({ + timeStart: 59400000, + timeEnd: 59760000, + }); + expect(result.rundown.entries['event-8']).toMatchObject({ + timeStart: 78300000, + timeEnd: 82560000, + }); }); it('handle leading and trailing whitespace', () => { const testData = [ - ['Time Start', 'Time End', ' Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour ', 'cue'], - ['4:30:00', '4:30:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], + [' ID', ' title ', 'Colour '], // <--- leading and trailing white space + ['event-a', 'title', '#F00'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: ' time start', - timeEnd: 'time end ', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, + id: 'id', + title: ' title', // <--- leading white space + colour: 'colour ', // <--- trailing white space }; - const result = parseExcel(testData, {}, importMap); - const { rundown } = parseRundown(result); - const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[]; - expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); //<--leading white space in MAP - expect((events.at(0) as OntimeEvent).timeEnd).toEqual(16200000); //<--trailing white space in MAP - expect((events.at(0) as OntimeEvent).title).toEqual('A song from the hearth'); //<--leading white space in Excel data - expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(1); + expect(result.rundown.entries['event-a']).toMatchObject({ + colour: '#F00', + id: 'event-a', + title: 'title', + }); }); it('parses link start and checks that is applicable', () => { const testData = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'Colour', - 'cue', - 'Link Start', - 'Timer type', - ], - ['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'], - ['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], - ['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden - ['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], - ['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'], - ['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks - [], + ['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'], + ['4:30:00', '9:45:00', 'A', '', 'count-down'], + ['9:45:00', '10:56:00', 'B', 'x', 'count-down'], + ['10:00:00', '16:36:00', 'C', 'x', 'count-down'], + ['21:45:00', '22:56:00', 'D', '', 'count-down'], + ['', '', 'BLOCK', 'x', 'block'], // <-- block with link + ['00:0:00', '23:56:00', 'E', 'x', 'count-down'], // <-- link past blocks ]; const importMap = { - worksheet: 'event schedule', timeStart: 'time start', - linkStart: 'link start', timeEnd: 'time end', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', + linkStart: 'link start', + id: 'id', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testData, {}, importMap); - const parseResult = parseRundown(result); - - cache.init(parseResult.rundown, parseResult.customFields); - const { rundown, order } = cache.get(); - - const firstId = order.at(0); // A - const secondId = order.at(1); // B - const thirdId = order.at(2); // C - const fourthId = order.at(3); // D - const fifthId = order.at(4); // Block - const sixthId = order.at(5); // E + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(6); + expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'BLOCK', 'E']); - if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) { - throw new Error('Unexpected value'); - } - - expect(rundown).toMatchObject({ - [firstId]: { - title: 'A', - timeStart: 16200000, + expect(result.rundown.entries).toMatchObject({ + A: { + linkStart: null, }, - [secondId]: { - title: 'B', - timeStart: (rundown[firstId] as OntimeEvent).timeEnd, - linkStart: (rundown[firstId] as OntimeEvent).id, + B: { + linkStart: 'true', // <--- this will be populated by the cache generation }, - [thirdId]: { - title: 'C', - timeStart: (rundown[secondId] as OntimeEvent).timeEnd, - linkStart: (rundown[secondId] as OntimeEvent).id, + C: { + linkStart: 'true', // <--- this will be populated by the cache generation }, - [fourthId]: { - title: 'D', - timeStart: 78300000, + D: { linkStart: null, }, - [fifthId]: { - title: 'BLOCK', + BLOCK: { type: SupportedEvent.Block, }, - [sixthId]: { - title: 'E', - timeStart: (rundown[fourthId] as OntimeEvent).timeEnd, - linkStart: (rundown[fourthId] as OntimeEvent).id, + E: { + linkStart: 'true', // <--- this will be populated by the cache generation }, }); }); @@ -1775,147 +710,79 @@ describe('parseExcel()', () => { it('#971 BUG: parses time fields and booleans', () => { const testData = [ [ - 'Cue', - 'Colour', + 'ID', 'Time Start', 'Time End', 'Duration', 'Link Start', - 'Title', - 'Note', 'Timer Type', 'End Action', 'Warning time', 'Danger time', - 'Public', - 'Skip', ], [ 'SETUP', - '', '1899-12-30T07:15:00.000Z', '1899-12-30T08:30:00.000Z', '', 'false', - 'Setup', - '', 'count-down', 'none', '15', '00:05:00', - 'FALSE', - 'TRUE', ], [ 'MEET1', - '#779BE7', '1899-12-30T08:30:00.000Z', '1899-12-30T10:00:00.000Z', '', 'false', - 'Meeting 1', - '', 'count-down', 'none', 15, '00:05:00', - 'TRUE', - 'FALSE', - ], - [ - 'MEET2', - '#779BE7', - '1899-12-30T10:00:00.000Z', - '', - '60', - 'false', - 'Meeting 2', - '', - 'count-down', - 'none', - '13', - '5', - 'TRUE', - 'FALSE', - ], - [ - 'lunch', - '#77C785', - '', - '1899-12-30T11:30:00.000Z', - '', - 'true', - 'Lunch', - '', - 'count-down', - 'none', - 13, - 5, - 'FALSE', - 'FALSE', - ], - [ - 'MEET3', - '#779BE7', - '1899-12-30T11:30:00.000Z', - '', - 90, - false, - 'Meeting 3', - '', - 'count-up', - 'none', - '11', - 5, - 'TRUE', - 'FALSE', ], - ['MEET4', '#779BE7', '', '', 30, true, 'Meeting 4', '', 'count-up', 'none', 11, '00:05:00', 'TRUE', 'FALSE'], + ['MEET2', '1899-12-30T10:00:00.000Z', '', '60', 'false', 'count-down', 'none', '13', '5'], + ['lunch', '', '1899-12-30T11:30:00.000Z', '', 'true', 'count-down', 'none', 13, 5], + ['MEET3', '1899-12-30T11:30:00.000Z', '', 90, false, 'count-up', 'none', '11', 5], + ['MEET4', '', '', 30, true, 'count-up', 'none', 11, '00:05:00'], ]; - const parsedData = parseExcel(testData, {}); - const { rundown } = parsedData; + const parsedData = parseExcel(testData, {}, 'bug-report'); // '15' as a string is parsed by smart time entry as minutes - expect(rundown[0]).toMatchObject({ - cue: 'SETUP', + expect(parsedData.rundown.entries['SETUP']).toMatchObject({ timeWarning: 15 * MILLIS_PER_MINUTE, }); // elements in bug report // 15 is a number, in which case we parse it as a minutes value - expect(rundown[1]).toMatchObject({ - cue: 'MEET1', + expect(parsedData.rundown.entries['MEET1']).toMatchObject({ timeWarning: 15 * MILLIS_PER_MINUTE, }); // in the case where a string is passed, we need to check whether it is an ISO 8601 date - expect(rundown[2]).toMatchObject({ - cue: 'MEET2', + expect(parsedData.rundown.entries['MEET2']).toMatchObject({ duration: 60 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[3]).toMatchObject({ - cue: 'lunch', + expect(parsedData.rundown.entries['lunch']).toMatchObject({ timeWarning: 13 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[4]).toMatchObject({ - cue: 'MEET3', + expect(parsedData.rundown.entries['MEET3']).toMatchObject({ duration: 90 * MILLIS_PER_MINUTE, - linkStart: false, + linkStart: null, timeWarning: 11 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[5]).toMatchObject({ - cue: 'MEET4', + expect(parsedData.rundown.entries['MEET4']).toMatchObject({ duration: 30 * MILLIS_PER_MINUTE, timeWarning: 11 * MILLIS_PER_MINUTE, - // if we get a boolean, we should just use that - linkStart: true, + linkStart: 'true', // if we get a boolean, we should just use that }); }); }); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index 03b39f46f4..8649b38a7a 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -1,45 +1,171 @@ -import { - CustomFields, - DatabaseModel, - OntimeEvent, - OntimeRundown, - Settings, - SupportedEvent, - URLPreset, -} from 'ontime-types'; +import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEvent, URLPreset } from 'ontime-types'; + +import { defaultRundown } from '../../models/dataModel.js'; import { parseCustomFields, parseProject, parseRundown, + parseRundowns, parseSettings, parseUrlPresets, parseViewSettings, sanitiseCustomFields, } from '../parserFunctions.js'; -describe('parseRundown()', () => { - it('returns an empty array if no rundown is given', () => { +describe('parseRundowns()', () => { + it('returns a default project rundown if nothing is given', () => { const errorEmitter = vi.fn(); - const result = parseRundown({}, errorEmitter); - expect(result.rundown).toEqual([]); + 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: '1', type: SupportedEvent.Event, title: 'test', skip: false }, // OK - { id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false }, // duplicate ID - {}, // no data - { id: '2', title: 'test 2', skip: false }, // no type - ] as OntimeRundown; - const { rundown: parsedRundown } = parseRundown({ rundown, customFields: {} }, errorEmitter); - expect(parsedRundown.length).toEqual(1); - expect(parsedRundown.at(0)).toMatchObject({ id: '1', type: SupportedEvent.Event, title: 'test', skip: false }); + const rundown = { + id: '', + title: '', + order: ['1', '2', '3', '4'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, title: 'test', skip: false } as OntimeEvent, // OK + '2': { id: '1', type: SupportedEvent.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: SupportedEvent.Event, + title: 'test', + skip: false, + }, + }); expect(errorEmitter).toHaveBeenCalled(); }); + + it('stringifies necessary values', () => { + const rundown = { + id: '', + title: '', + order: ['1', '2'], + entries: { + // @ts-expect-error -- testing external data which could be incorrect + '1': { id: '1', type: SupportedEvent.Event, cue: 101 } as OntimeEvent, + // @ts-expect-error -- testing external data which could be incorrect + '2': { id: '2', type: SupportedEvent.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'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.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'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.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'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.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); + }); }); describe('parseProject()', () => { @@ -48,34 +174,15 @@ describe('parseProject()', () => { const result = parseProject({}, errorEmitter); expect(result).toBeTypeOf('object'); expect(errorEmitter).toHaveBeenCalledOnce(); - }); - - it('test migration with adding the logo field v3.8.0', () => { - const errorEmitter = vi.fn(); - const result = parseProject( - { - //@ts-expect-error -- checking migration when the logo field is added - project: { - title: 'title', - description: 'description', - publicUrl: 'publicUrl', - publicInfo: 'publicInfo', - backstageUrl: 'backstageUrl', - backstageInfo: 'backstageInfo', - }, - }, - errorEmitter, - ); - expect(result).toStrictEqual({ - title: 'title', - description: 'description', - publicUrl: 'publicUrl', - publicInfo: 'publicInfo', - backstageUrl: 'backstageUrl', - backstageInfo: 'backstageInfo', + expect(result).toMatchObject({ + title: '', + description: '', + publicUrl: '', + publicInfo: '', + backstageUrl: '', + backstageInfo: '', projectLogo: null, }); - expect(errorEmitter).not.toHaveBeenCalled(); }); }); @@ -85,9 +192,17 @@ describe('parseSettings()', () => { }); it('returns an a base model as long as we have the app and version', () => { - const minimalSettings = { app: 'ontime', version: '1' } as Settings; - const result = parseSettings({ settings: minimalSettings }); + const result = parseSettings({ settings: { app: 'ontime', version: '1' } as Settings }); expect(result).toBeTypeOf('object'); + expect(result).toMatchObject({ + app: 'ontime', + version: expect.any(String), + serverPort: 4001, + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }); }); }); @@ -219,17 +334,6 @@ describe('sanitiseCustomFields()', () => { expect(sanitationResult).toStrictEqual(expectedCustomFields); }); - it('allow old keys', () => { - const customFields: CustomFields = { - test: { label: 'Test', type: 'string', colour: 'red' }, - }; - const expectedCustomFields: CustomFields = { - test: { label: 'Test', type: 'string', colour: 'red' }, - }; - const sanitationResult = sanitiseCustomFields(customFields); - expect(sanitationResult).toStrictEqual(expectedCustomFields); - }); - it('labels with space', () => { const customFields: CustomFields = { Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, @@ -260,100 +364,120 @@ describe('sanitiseCustomFields()', () => { describe('parseRundown() linking', () => { it('returns linked events', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', '2'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, skip: false, } as OntimeEvent, - { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[1]).toMatchObject({ - id: '2', - linkStart: '1', + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: ['1', '2'], + entries: { + '2': { + linkStart: '1', + }, + }, }); }); it('returns unlinked if no previous', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', '2'], + entries: { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[0]).toMatchObject({ - id: '2', - linkStart: null, + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: ['2'], + entries: { + '2': { + linkStart: null, + }, + }, }); }); it('returns linked events past blocks and delays', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', 'delay1', '2', 'block1', '3'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, skip: false, } as OntimeEvent, - { + delay1: { id: 'delay1', type: SupportedEvent.Delay, duration: 0, }, - { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - { + block1: { id: 'block1', type: SupportedEvent.Block, title: '', - }, - { + } as OntimeBlock, + '3': { id: '3', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[0]).toMatchObject({ - id: '1', - cue: '1', - }); - // skip delay - expect(result.rundown[2]).toMatchObject({ - id: '2', - cue: '2', - linkStart: '1', - }); - // skip block - expect(result.rundown[4]).toMatchObject({ - id: '3', - cue: '3', - linkStart: '2', + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: rundown.order, + entries: { + '1': { + id: '1', + cue: '1', + }, + '2': { + id: '2', + cue: '2', + linkStart: '1', + }, + '3': { + id: '3', + cue: '3', + linkStart: '2', + }, + }, }); }); }); diff --git a/apps/server/src/utils/__tests__/time.test.ts b/apps/server/src/utils/__tests__/time.test.ts index 31febd9a0e..6367874b19 100644 --- a/apps/server/src/utils/__tests__/time.test.ts +++ b/apps/server/src/utils/__tests__/time.test.ts @@ -2,68 +2,38 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { parseExcelDate } from '../time.js'; describe('parseExcelDate', () => { + // TODO: our parsing currently does not use UTC, so the tests can not be done in CI describe.todo('parses a valid date string as expected from excel', () => { - const testCases = [ - { - fromExcel: '1899-12-30T00:00:00.000Z', - expected: 3600000, - }, - { - fromExcel: '1899-12-30T00:10:00.000Z', - expected: 4200000, - }, - { - fromExcel: '1899-12-30T01:00:00.000Z', - expected: 7200000, - }, - { - fromExcel: '1899-12-30T07:00:00.000Z', - expected: 28800000, - }, - { - fromExcel: '1899-12-30T08:00:10.000Z', - expected: 32410000, - }, - { - fromExcel: '1899-12-30T08:30:00.000Z', - expected: 34200000, - }, - ]; - - for (const scenario of testCases) { - it(`handles ${scenario.fromExcel}`, () => { - expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected); - }); - } + test.each([ + ['1899-12-30T00:00:00.000Z', 3600000], + ['1899-12-30T00:10:00.000Z', 4200000], + ['1899-12-30T01:00:00.000Z', 7200000], + ['1899-12-30T07:00:00.000Z', 28800000], + ['1899-12-30T08:00:10.000Z', 32410000], + ['1899-12-30T08:30:00.000Z', 34200000], + ])(`handles %s`, (fromExcel, expected) => { + expect(parseExcelDate(fromExcel)).toBe(expected); + }); }); describe('parses a time string that passes validation', () => { - const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm']; - validFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).not.toBe(0); - }); - }); + test.each([['10:00:00'], ['10:00'], ['10:00AM'], ['10:00am'], ['10:00PM'], ['10:00pm']])( + `handles %s`, + (fromExcel) => { + expect(parseExcelDate(fromExcel)).not.toBe(0); + }, + ); }); describe('uses numeric fields as minutes', () => { - const invalidFields = [1, 10, 100]; - invalidFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).toBe(field * MILLIS_PER_MINUTE); - }); + test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => { + expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE); }); }); describe('returns 0 on other strings', () => { - const invalidFields = ['test', '']; - invalidFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).toBe(0); - }); + test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => { + expect(parseExcelDate(fromExcel)).toBe(0); }); }); }); diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index 759a6ee293..d4da7c4416 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -106,7 +106,7 @@ export async function copyDirectory(src: string, dest: string) { } } -/** +/** * workaround avoids origin errors in docker deployments * EXDEV cross-device link not permitted */ diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index ed7afd908d..7a6b80c752 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -13,11 +13,12 @@ import { import { CustomFields, DatabaseModel, - EventCustomFields, + EntryCustomFields, isOntimeBlock, LogOrigin, + OntimeBlock, OntimeEvent, - OntimeRundown, + Rundown, SupportedEvent, TimerType, TimeStrategy, @@ -28,17 +29,14 @@ import { logger } from '../classes/Logger.js'; import { event as eventDef } from '../models/eventsDefinition.js'; import { makeString } from './parserUtils.js'; -import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; +import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; +import { Merge } from 'ts-essentials'; export type ErrorEmitter = (message: string) => void; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; -type ExcelData = Pick & { - rundownMetadata: Record; -}; - function parseBooleanString(value: unknown): boolean { if (typeof value === 'boolean') { return value; @@ -83,9 +81,14 @@ export function getCustomFieldData( export const parseExcel = ( excelData: unknown[][], existingCustomFields: CustomFields, + sheetName: string = 'Rundown from excel', options?: Partial, -): ExcelData => { - const rundownMetadata = {}; +): { + rundown: Rundown; + customFields: CustomFields; + rundownMetadata: Record; +} => { + const rundownMetadata: Record = {}; const importMap: ImportMap = { ...defaultImportMap, ...options }; for (const [key, value] of Object.entries(importMap)) { @@ -95,7 +98,13 @@ export const parseExcel = ( } const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields); - const rundown: OntimeRundown = []; + const rundown: Rundown = { + id: generateId(), + title: sheetName, + order: [], + entries: {}, + revision: 0, + }; // title stuff: strings let titleIndex: number | null = null; @@ -205,8 +214,8 @@ export const parseExcel = ( }, } as const; - const event: any = {}; - const eventCustomFields: EventCustomFields = {}; + const entry: Partial> = {}; + const entryCustomFields: EntryCustomFields = {}; for (let j = 0; j < row.length; j++) { const column = row[j]; @@ -214,48 +223,50 @@ export const parseExcel = ( if (j === timerTypeIndex) { const maybeTimeType = makeString(column, ''); if (maybeTimeType === 'block') { - event.type = SupportedEvent.Block; + // we leave this as a clue for the object filtering later on + entry.type = SupportedEvent.Block; } else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) { - event.type = SupportedEvent.Event; - event.timerType = validateTimerType(maybeTimeType); + // @ts-expect-error -- we leave this as a clue for the object filtering later on + entry.type = SupportedEvent.Event; + entry.timerType = validateTimerType(maybeTimeType); } else { // if it is not a block or a known type, we dont import it return; } } else if (j === titleIndex) { - event.title = makeString(column, ''); + entry.title = makeString(column, ''); } else if (j === timeStartIndex) { - event.timeStart = parseExcelDate(column); + entry.timeStart = parseExcelDate(column); } else if (j === linkStartIndex) { - event.linkStart = parseBooleanString(column); + entry.linkStart = parseBooleanString(column) ? 'true' : null; } else if (j === timeEndIndex) { - event.timeEnd = parseExcelDate(column); + entry.timeEnd = parseExcelDate(column); } else if (j === durationIndex) { - event.duration = parseExcelDate(column); + entry.duration = parseExcelDate(column); } else if (j === cueIndex) { - event.cue = makeString(column, ''); + entry.cue = makeString(column, ''); } else if (j === countToEndIndex) { - event.countToEnd = parseBooleanString(column); + entry.countToEnd = parseBooleanString(column); } else if (j === isPublicIndex) { - event.isPublic = parseBooleanString(column); + entry.isPublic = parseBooleanString(column); } else if (j === skipIndex) { - event.skip = parseBooleanString(column); + entry.skip = parseBooleanString(column); } else if (j === notesIndex) { - event.note = makeString(column, ''); + entry.note = makeString(column, ''); } else if (j === endActionIndex) { - event.endAction = validateEndAction(column); + entry.endAction = validateEndAction(column); } else if (j === timeWarningIndex) { - event.timeWarning = parseExcelDate(column); + entry.timeWarning = parseExcelDate(column); } else if (j === timeDangerIndex) { - event.timeDanger = parseExcelDate(column); + entry.timeDanger = parseExcelDate(column); } else if (j === colourIndex) { - event.colour = makeString(column, ''); + entry.colour = makeString(column, ''); } else if (j === entryIdIndex) { - event.id = encodeURIComponent(makeString(column, undefined)); + entry.id = encodeURIComponent(makeString(column, undefined)); } else if (j in customFieldIndexes) { const importKey = customFieldIndexes[j]; const ontimeKey = customFieldImportKeys[importKey]; - eventCustomFields[ontimeKey] = makeString(column, ''); + entryCustomFields[ontimeKey] = makeString(column, ''); } else { // 2. if there is no flag, lets see if we know the field type if (typeof column === 'string') { @@ -282,20 +293,32 @@ export const parseExcel = ( } } - // if any data was found in row, push to array - const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; - if (keysFound > 0) { - // if it is a Block type drop all other filed - if (isOntimeBlock(event)) { - rundown.push({ type: event.type, id: event.id, title: event.title }); - } else { - if (timerTypeIndex === null) { - event.timerType = TimerType.CountDown; - event.type = SupportedEvent.Event; - } - rundown.push({ ...event, custom: { ...eventCustomFields } }); - } + // if we didnt find any keys (empty row, or some other data), skip making an event + const keysFound = Object.keys(entry).length + Object.keys(entryCustomFields).length; + if (keysFound === 0) { + return; + } + + const id = entry.id || generateId(); + // from excel, we can only get blocks and events + if (isOntimeBlock(entry)) { + const block: OntimeBlock = { ...entry, custom: { ...entryCustomFields } }; + rundown.order.push(id); + rundown.entries[id] = block; + return; } + + const event = { + ...entry, + custom: { ...entryCustomFields }, + type: SupportedEvent.Event, + } as OntimeEvent; + + if (timerTypeIndex === null) { + event.timerType = TimerType.CountDown; + } + rundown.order.push(id); + rundown.entries[id] = event; }); return { @@ -327,11 +350,10 @@ export function parseDatabaseModel(jsonData: Partial): { data: Da }; // we need to parse the custom fields first so they can be used in validating events - // TODO: can we improve the readability of the error? - const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown')); + const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown')); const data: DatabaseModel = { - rundown, + rundowns, project: parseProject(jsonData, makeEmitError('Project')), settings, viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')), @@ -394,6 +416,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial, emitError?: ErrorEmitter, -): { customFields: CustomFields; rundown: OntimeRundown } { +): { customFields: CustomFields; rundowns: ProjectRundowns } { // check custom fields first const parsedCustomFields = parseCustomFields(data, emitError); - if (!data.rundown) { + if (!data.rundowns || isObjectEmpty(data.rundowns)) { emitError?.('No data found to import'); - return { customFields: parsedCustomFields, rundown: [] }; + return { + customFields: parsedCustomFields, + rundowns: { + default: { + ...defaultRundown, + }, + }, + }; } - console.log('Found rundown, importing...'); + 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: [], + revision: rundown.revision ?? 1, + }; - const rundown: OntimeRundown = []; let eventIndex = 0; let previousId: string | null = null; - const ids: string[] = []; - for (const event of data.rundown) { - if (ids.includes(event.id)) { + 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 = event.id || generateId(); + const id = entryId; let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; if (isOntimeEvent(event)) { - const maybeEvent = { ...event, id }; + const maybeEvent = { ...event }; if (event.linkStart) { maybeEvent.linkStart = previousId; @@ -78,20 +120,29 @@ export function parseRundown( } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; } else if (isOntimeBlock(event)) { - newEvent = { ...blockDef, title: event.title, id }; + 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) { - rundown.push(newEvent); - ids.push(id); + parsedRundown.entries[id] = newEvent; + parsedRundown.order.push(id); } } - console.log(`Uploaded rundown with ${rundown.length} entries`); - return { customFields: parsedCustomFields, rundown }; + console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`); + return parsedRundown; } /** @@ -215,10 +266,15 @@ export function sanitiseCustomFields(data: object): CustomFields { continue; } - const keyFromLabel = customFieldLabelToKey(field.label); - // Test label and key cohesion, but allow old lowercased keys to stay - // TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) - const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; + // Test label and key cohesion + const key = (() => { + const keyFromLabel = customFieldLabelToKey(field.label); + if (keyFromLabel === null) { + return originalKey; + } + return originalKey.toLowerCase() === keyFromLabel.toLowerCase() ? originalKey : keyFromLabel; + })(); + if (key in newCustomFields) { continue; } diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 6224541f91..c30468f976 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -1,408 +1,469 @@ { - "rundown": [ - { - "id": "32d31", - "type": "event", - "title": "Albania", - "timeStart": 36000000, - "timeEnd": 37200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.01", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.01", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sekret", - "artist": "Ronela Hajati" + "rundowns": { + "demo": { + "id": "demo", + "title": "Eurovision Demo", + "order": [ + "32d31", + "21cd2", + "0b371", + "3cd28", + "e457f", + "01e85", + "1c420", + "b7737", + "d3a80", + "8276c", + "2340b", + "cb90b", + "503c4", + "5e965", + "bab4a", + "d3eb1" + ], + "entries": { + "32d31": { + "type": "event", + "id": "32d31", + "cue": "SF1.01", + "title": "Albania", + "note": "SF1.01", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 36000000, + "timeEnd": 37200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sekret", + "artist": "Ronela Hajati" + } + }, + "21cd2": { + "type": "event", + "id": "21cd2", + "cue": "SF1.02", + "title": "Latvia", + "note": "SF1.02", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 37500000, + "timeEnd": 38700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Eat Your Salad", + "artist": "Citi Zeni" + } + }, + "0b371": { + "type": "event", + "id": "0b371", + "cue": "SF1.03", + "title": "Lithuania", + "note": "SF1.03", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 39000000, + "timeEnd": 40200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sentimentai", + "artist": "Monika Liu" + } + }, + "3cd28": { + "type": "event", + "id": "3cd28", + "cue": "SF1.04", + "title": "Switzerland", + "note": "SF1.04", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 40500000, + "timeEnd": 41700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Boys Do Cry", + "artist": "Marius Bear" + } + }, + "e457f": { + "type": "event", + "id": "e457f", + "cue": "SF1.05", + "title": "Slovenia", + "note": "SF1.05", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 42000000, + "timeEnd": 43200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Disko", + "artist": "LPS" + } + }, + "01e85": { + "type": "block", + "id": "01e85", + "title": "Lunch break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "1c420": { + "type": "event", + "id": "1c420", + "cue": "SF1.06", + "title": "Ukraine", + "note": "SF1.06", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 47100000, + "timeEnd": 48300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 3900000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Stefania", + "artist": "Kalush Orchestra" + } + }, + "b7737": { + "type": "event", + "id": "b7737", + "cue": "SF1.07", + "title": "Bulgaria", + "note": "SF1.07", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 48600000, + "timeEnd": 49800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Intention", + "artist": "Intelligent Music Project" + } + }, + "d3a80": { + "type": "event", + "id": "d3a80", + "cue": "SF1.08", + "title": "Netherlands", + "note": "SF1.08", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 50100000, + "timeEnd": 51300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "De Diepte", + "artist": "S10" + } + }, + "8276c": { + "type": "event", + "id": "8276c", + "cue": "SF1.09", + "title": "Moldova", + "note": "SF1.09", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 51600000, + "timeEnd": 52800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Trenuletul", + "artist": "Zdob si Zdub" + } + }, + "2340b": { + "type": "event", + "id": "2340b", + "cue": "SF1.10", + "title": "Portugal", + "note": "SF1.10", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 53100000, + "timeEnd": 54300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Saudade Saudade", + "artist": "Maro" + } + }, + "cb90b": { + "type": "block", + "id": "cb90b", + "title": "Afternoon break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "503c4": { + "type": "event", + "id": "503c4", + "cue": "SF1.11", + "title": "Croatia", + "note": "SF1.11", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 56100000, + "timeEnd": 57300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 1800000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Guilty Pleasure", + "artist": "Mia Dimsic" + } + }, + "5e965": { + "type": "event", + "id": "5e965", + "cue": "SF1.12", + "title": "Denmark", + "note": "SF1.12", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 57600000, + "timeEnd": 58800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "The Show", + "artist": "Reddi" + } + }, + "bab4a": { + "type": "event", + "id": "bab4a", + "cue": "SF1.13", + "title": "Austria", + "note": "SF1.13", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 59100000, + "timeEnd": 60300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Halo", + "artist": "LUM!X & Pia Maria" + } + }, + "d3eb1": { + "type": "event", + "id": "d3eb1", + "cue": "SF1.14", + "title": "Greece", + "note": "SF1.14", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 60600000, + "timeEnd": 61800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Die Together", + "artist": "Amanda Tenfjord" + } + } + }, + "revision": 0 } }, - { - "id": "21cd2", - "type": "event", - "title": "Latvia", - "timeStart": 37500000, - "timeEnd": 38700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.02", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.02", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Eat Your Salad", - "artist": "Citi Zeni" - } - }, - { - "id": "0b371", - "type": "event", - "title": "Lithuania", - "timeStart": 39000000, - "timeEnd": 40200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.03", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.03", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sentimentai", - "artist": "Monika Liu" - } - }, - { - "id": "3cd28", - "type": "event", - "title": "Switzerland", - "timeStart": 40500000, - "timeEnd": 41700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.04", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.04", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Boys Do Cry", - "artist": "Marius Bear" - } - }, - { - "id": "e457f", - "type": "event", - "title": "Slovenia", - "timeStart": 42000000, - "timeEnd": 43200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.05", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.05", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Disko", - "artist": "LPS" - } - }, - { - "title": "Lunch break", - "type": "block", - "id": "01e85" - }, - { - "id": "1c420", - "type": "event", - "title": "Ukraine", - "timeStart": 47100000, - "timeEnd": 48300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.06", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.06", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Stefania", - "artist": "Kalush Orchestra" - } - }, - { - "id": "b7737", - "type": "event", - "title": "Bulgaria", - "timeStart": 48600000, - "timeEnd": 49800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.07", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.07", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Intention", - "artist": "Intelligent Music Project" - } - }, - { - "id": "d3a80", - "type": "event", - "title": "Netherlands", - "timeStart": 50100000, - "timeEnd": 51300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.08", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.08", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "De Diepte", - "artist": "S10" - } - }, - { - "id": "8276c", - "type": "event", - "title": "Moldova", - "timeStart": 51600000, - "timeEnd": 52800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.09", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.09", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Trenuletul", - "artist": "Zdob si Zdub" - } - }, - { - "id": "2340b", - "type": "event", - "title": "Portugal", - "timeStart": 53100000, - "timeEnd": 54300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.10", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.10", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Saudade Saudade", - "artist": "Maro" - } - }, - { - "title": "Afternoon break", - "type": "block", - "id": "cb90b" - }, - { - "id": "503c4", - "type": "event", - "title": "Croatia", - "timeStart": 56100000, - "timeEnd": 57300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.11", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.11", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Guilty Pleasure", - "artist": "Mia Dimsic" - } - }, - { - "id": "5e965", - "type": "event", - "title": "Denmark", - "timeStart": 57600000, - "timeEnd": 58800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.12", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.12", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "The Show", - "artist": "Reddi" - } - }, - { - "id": "bab4a", - "type": "event", - "title": "Austria", - "timeStart": 59100000, - "timeEnd": 60300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.13", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.13", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Halo", - "artist": "LUM!X & Pia Maria" - } - }, - { - "id": "d3eb1", - "type": "event", - "title": "Greece", - "timeStart": 60600000, - "timeEnd": 61800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.14", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.14", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Die Together", - "artist": "Amanda Tenfjord" - } - } -], "project": { "title": "Eurovision Song Contest", "description": "Turin 2022", @@ -414,7 +475,7 @@ }, "settings": { "app": "ontime", - "version": "3.10.2", + "version": "-", "serverPort": 4001, "editorKey": null, "operatorKey": null, @@ -422,12 +483,24 @@ "language": "en" }, "viewSettings": { - "overrideStyles": false, - "normalColor": "#ffffffcc", - "warningColor": "#FFAB33", "dangerColor": "#ED3333", "endMessage": "", - "freezeEnd": false + "freezeEnd": false, + "normalColor": "#ffffffcc", + "overrideStyles": false, + "warningColor": "#FFAB33" + }, + "customFields": { + "song": { + "label": "Song", + "type": "string", + "colour": "#339E4E" + }, + "artist": { + "label": "Artist", + "type": "string", + "colour": "#3E75E8" + } }, "urlPresets": [ { @@ -442,17 +515,5 @@ "oscPortIn": 8888, "triggers": [], "automations": {} - }, - "customFields": { - "song": { - "type": "string", - "colour": "", - "label": "song" - }, - "artist": { - "type": "string", - "colour": "", - "label": "artist" - } } -} \ No newline at end of file +} diff --git a/apps/spec/roll.md b/apps/spec/roll.md index b41664a410..00a19a5b22 100644 --- a/apps/spec/roll.md +++ b/apps/spec/roll.md @@ -12,7 +12,7 @@ It can be user either on its own, or as in conjunction with manual playback to a ## Implementation details ### starting to roll -> RuntimeService.roll(rundown: OntimeRundown) +> RuntimeService.roll(rundown) When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty. diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index 28b363b899..02b34fd0b5 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'; import { readFile } from 'fs/promises'; -const fileToUpload = 'e2e/tests/fixtures/test-db.json'; -const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json'; +const fileToUpload = 'e2e/tests/fixtures/e2e-test-db.json'; +const fileToDownload = 'e2e/tests/fixtures/tmp/e2e-test-db.json'; test('project file upload', async ({ page }) => { await page.goto('http://localhost:4001/editor'); @@ -46,7 +46,7 @@ test('project file download', async ({ page }) => { const downloadPromise = page.waitForEvent('download'); await page - .getByRole('row', { name: RegExp('^test-db') }) + .getByRole('row', { name: /^e2e-test-db/ }) .getByLabel('Options') .click(); await page.getByRole('menuitem', { name: 'Download' }).click(); diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json new file mode 100644 index 0000000000..c30468f976 --- /dev/null +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -0,0 +1,519 @@ +{ + "rundowns": { + "demo": { + "id": "demo", + "title": "Eurovision Demo", + "order": [ + "32d31", + "21cd2", + "0b371", + "3cd28", + "e457f", + "01e85", + "1c420", + "b7737", + "d3a80", + "8276c", + "2340b", + "cb90b", + "503c4", + "5e965", + "bab4a", + "d3eb1" + ], + "entries": { + "32d31": { + "type": "event", + "id": "32d31", + "cue": "SF1.01", + "title": "Albania", + "note": "SF1.01", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 36000000, + "timeEnd": 37200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sekret", + "artist": "Ronela Hajati" + } + }, + "21cd2": { + "type": "event", + "id": "21cd2", + "cue": "SF1.02", + "title": "Latvia", + "note": "SF1.02", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 37500000, + "timeEnd": 38700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Eat Your Salad", + "artist": "Citi Zeni" + } + }, + "0b371": { + "type": "event", + "id": "0b371", + "cue": "SF1.03", + "title": "Lithuania", + "note": "SF1.03", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 39000000, + "timeEnd": 40200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sentimentai", + "artist": "Monika Liu" + } + }, + "3cd28": { + "type": "event", + "id": "3cd28", + "cue": "SF1.04", + "title": "Switzerland", + "note": "SF1.04", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 40500000, + "timeEnd": 41700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Boys Do Cry", + "artist": "Marius Bear" + } + }, + "e457f": { + "type": "event", + "id": "e457f", + "cue": "SF1.05", + "title": "Slovenia", + "note": "SF1.05", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 42000000, + "timeEnd": 43200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Disko", + "artist": "LPS" + } + }, + "01e85": { + "type": "block", + "id": "01e85", + "title": "Lunch break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "1c420": { + "type": "event", + "id": "1c420", + "cue": "SF1.06", + "title": "Ukraine", + "note": "SF1.06", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 47100000, + "timeEnd": 48300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 3900000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Stefania", + "artist": "Kalush Orchestra" + } + }, + "b7737": { + "type": "event", + "id": "b7737", + "cue": "SF1.07", + "title": "Bulgaria", + "note": "SF1.07", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 48600000, + "timeEnd": 49800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Intention", + "artist": "Intelligent Music Project" + } + }, + "d3a80": { + "type": "event", + "id": "d3a80", + "cue": "SF1.08", + "title": "Netherlands", + "note": "SF1.08", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 50100000, + "timeEnd": 51300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "De Diepte", + "artist": "S10" + } + }, + "8276c": { + "type": "event", + "id": "8276c", + "cue": "SF1.09", + "title": "Moldova", + "note": "SF1.09", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 51600000, + "timeEnd": 52800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Trenuletul", + "artist": "Zdob si Zdub" + } + }, + "2340b": { + "type": "event", + "id": "2340b", + "cue": "SF1.10", + "title": "Portugal", + "note": "SF1.10", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 53100000, + "timeEnd": 54300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Saudade Saudade", + "artist": "Maro" + } + }, + "cb90b": { + "type": "block", + "id": "cb90b", + "title": "Afternoon break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "503c4": { + "type": "event", + "id": "503c4", + "cue": "SF1.11", + "title": "Croatia", + "note": "SF1.11", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 56100000, + "timeEnd": 57300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 1800000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Guilty Pleasure", + "artist": "Mia Dimsic" + } + }, + "5e965": { + "type": "event", + "id": "5e965", + "cue": "SF1.12", + "title": "Denmark", + "note": "SF1.12", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 57600000, + "timeEnd": 58800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "The Show", + "artist": "Reddi" + } + }, + "bab4a": { + "type": "event", + "id": "bab4a", + "cue": "SF1.13", + "title": "Austria", + "note": "SF1.13", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 59100000, + "timeEnd": 60300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Halo", + "artist": "LUM!X & Pia Maria" + } + }, + "d3eb1": { + "type": "event", + "id": "d3eb1", + "cue": "SF1.14", + "title": "Greece", + "note": "SF1.14", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 60600000, + "timeEnd": 61800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Die Together", + "artist": "Amanda Tenfjord" + } + } + }, + "revision": 0 + } + }, + "project": { + "title": "Eurovision Song Contest", + "description": "Turin 2022", + "publicUrl": "www.getontime.no", + "publicInfo": "Rehearsal Schedule - Turin 2022", + "backstageUrl": "www.github.com/cpvalente/ontime", + "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", + "projectLogo": null + }, + "settings": { + "app": "ontime", + "version": "-", + "serverPort": 4001, + "editorKey": null, + "operatorKey": null, + "timeFormat": "24", + "language": "en" + }, + "viewSettings": { + "dangerColor": "#ED3333", + "endMessage": "", + "freezeEnd": false, + "normalColor": "#ffffffcc", + "overrideStyles": false, + "warningColor": "#FFAB33" + }, + "customFields": { + "song": { + "label": "Song", + "type": "string", + "colour": "#339E4E" + }, + "artist": { + "label": "Artist", + "type": "string", + "colour": "#3E75E8" + } + }, + "urlPresets": [ + { + "enabled": true, + "alias": "test", + "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" + } + ], + "automation": { + "enabledAutomations": false, + "enabledOscIn": true, + "oscPortIn": 8888, + "triggers": [], + "automations": {} + } +} diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json deleted file mode 100644 index 6224541f91..0000000000 --- a/e2e/tests/fixtures/test-db.json +++ /dev/null @@ -1,458 +0,0 @@ -{ - "rundown": [ - { - "id": "32d31", - "type": "event", - "title": "Albania", - "timeStart": 36000000, - "timeEnd": 37200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.01", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.01", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sekret", - "artist": "Ronela Hajati" - } - }, - { - "id": "21cd2", - "type": "event", - "title": "Latvia", - "timeStart": 37500000, - "timeEnd": 38700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.02", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.02", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Eat Your Salad", - "artist": "Citi Zeni" - } - }, - { - "id": "0b371", - "type": "event", - "title": "Lithuania", - "timeStart": 39000000, - "timeEnd": 40200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.03", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.03", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sentimentai", - "artist": "Monika Liu" - } - }, - { - "id": "3cd28", - "type": "event", - "title": "Switzerland", - "timeStart": 40500000, - "timeEnd": 41700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.04", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.04", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Boys Do Cry", - "artist": "Marius Bear" - } - }, - { - "id": "e457f", - "type": "event", - "title": "Slovenia", - "timeStart": 42000000, - "timeEnd": 43200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.05", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.05", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Disko", - "artist": "LPS" - } - }, - { - "title": "Lunch break", - "type": "block", - "id": "01e85" - }, - { - "id": "1c420", - "type": "event", - "title": "Ukraine", - "timeStart": 47100000, - "timeEnd": 48300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.06", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.06", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Stefania", - "artist": "Kalush Orchestra" - } - }, - { - "id": "b7737", - "type": "event", - "title": "Bulgaria", - "timeStart": 48600000, - "timeEnd": 49800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.07", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.07", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Intention", - "artist": "Intelligent Music Project" - } - }, - { - "id": "d3a80", - "type": "event", - "title": "Netherlands", - "timeStart": 50100000, - "timeEnd": 51300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.08", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.08", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "De Diepte", - "artist": "S10" - } - }, - { - "id": "8276c", - "type": "event", - "title": "Moldova", - "timeStart": 51600000, - "timeEnd": 52800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.09", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.09", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Trenuletul", - "artist": "Zdob si Zdub" - } - }, - { - "id": "2340b", - "type": "event", - "title": "Portugal", - "timeStart": 53100000, - "timeEnd": 54300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.10", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.10", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Saudade Saudade", - "artist": "Maro" - } - }, - { - "title": "Afternoon break", - "type": "block", - "id": "cb90b" - }, - { - "id": "503c4", - "type": "event", - "title": "Croatia", - "timeStart": 56100000, - "timeEnd": 57300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.11", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.11", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Guilty Pleasure", - "artist": "Mia Dimsic" - } - }, - { - "id": "5e965", - "type": "event", - "title": "Denmark", - "timeStart": 57600000, - "timeEnd": 58800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.12", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.12", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "The Show", - "artist": "Reddi" - } - }, - { - "id": "bab4a", - "type": "event", - "title": "Austria", - "timeStart": 59100000, - "timeEnd": 60300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.13", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.13", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Halo", - "artist": "LUM!X & Pia Maria" - } - }, - { - "id": "d3eb1", - "type": "event", - "title": "Greece", - "timeStart": 60600000, - "timeEnd": 61800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.14", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.14", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Die Together", - "artist": "Amanda Tenfjord" - } - } -], - "project": { - "title": "Eurovision Song Contest", - "description": "Turin 2022", - "publicUrl": "www.getontime.no", - "publicInfo": "Rehearsal Schedule - Turin 2022", - "backstageUrl": "www.github.com/cpvalente/ontime", - "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", - "projectLogo": null - }, - "settings": { - "app": "ontime", - "version": "3.10.2", - "serverPort": 4001, - "editorKey": null, - "operatorKey": null, - "timeFormat": "24", - "language": "en" - }, - "viewSettings": { - "overrideStyles": false, - "normalColor": "#ffffffcc", - "warningColor": "#FFAB33", - "dangerColor": "#ED3333", - "endMessage": "", - "freezeEnd": false - }, - "urlPresets": [ - { - "enabled": true, - "alias": "test", - "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" - } - ], - "automation": { - "enabledAutomations": false, - "enabledOscIn": true, - "oscPortIn": 8888, - "triggers": [], - "automations": {} - }, - "customFields": { - "song": { - "type": "string", - "colour": "", - "label": "song" - }, - "artist": { - "type": "string", - "colour": "", - "label": "artist" - } - } -} \ No newline at end of file diff --git a/package.json b/package.json index f1b45ab728..51bc5c72c5 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,11 @@ "dist-win": "turbo run dist-win", "dist-mac": "turbo run dist-mac", "dist-linux": "turbo run dist-linux", - "e2e": "cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts", + "e2e": "pnpm clear-temp && cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts", "e2e:ui": "cross-env DEBUG=pw:webserver npx playwright test --ui -c playwright.config.ts", "e2e:i": "npx playwright codegen", - "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules" + "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules", + "clear-temp": "rm -rf e2e/tests/fixtures/tmp" }, "devDependencies": { "@playwright/test": "^1.49.1", diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index f2d7538e92..cea3ff470d 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,17 +1,8 @@ import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js'; -import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js'; - -type EventId = string; -export type NormalisedRundown = Record; - -export interface RundownCached { - rundown: NormalisedRundown; - order: EventId[]; - revision: number; -} +import type { OntimeEntry } from '../../definitions/core/Rundown.type.js'; export type PatchWithId = Partial & { id: string }; -export type EventPostPayload = Partial & { +export type EventPostPayload = Partial & { after?: string; before?: string; }; @@ -20,3 +11,10 @@ export type TransientEventPayload = Partial; -export type EventCustomFields = Record; +export type EntryCustomFields = Record; diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 07b8362cd3..5fd073071e 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -1,4 +1,6 @@ -import type { EndAction, EventCustomFields, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js'; +import type { EndAction, EntryCustomFields, MaybeNumber, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js'; + +export type EntryId = string; export enum SupportedEvent { Event = 'event', @@ -8,7 +10,7 @@ export enum SupportedEvent { export type OntimeBaseEvent = { type: SupportedEvent; - id: string; + id: EntryId; }; export type OntimeDelay = OntimeBaseEvent & { @@ -19,6 +21,18 @@ export type OntimeDelay = OntimeBaseEvent & { export type OntimeBlock = OntimeBaseEvent & { type: SupportedEvent.Block; title: string; + note: string; + events: EntryId[]; + skip: boolean; + colour: string; + custom: EntryCustomFields; + // !==== RUNTIME METADATA ====! // + revision: number; + startTime: MaybeNumber; // calculated at runtime + endTime: MaybeNumber; // calculated at runtime + duration: number; // calculated at runtime + isFirstLinked: boolean; // calculated at runtime, whether the first event is linked + numEvents: number; // calculated at runtime }; export type OntimeEvent = OntimeBaseEvent & { @@ -37,14 +51,16 @@ export type OntimeEvent = OntimeBaseEvent & { isPublic: boolean; skip: boolean; colour: string; + timeWarning: number; + timeDanger: number; + custom: EntryCustomFields; + triggers?: Trigger[]; + // !==== RUNTIME METADATA ====! // + currentBlock: EntryId | null; revision: number; delay: number; // calculated at runtime dayOffset: number; // calculated at runtime gap: number; // calculated at runtime - timeWarning: number; - timeDanger: number; - custom: EventCustomFields; - triggers?: Trigger[]; }; export type PlayableEvent = OntimeEvent & { skip: false }; diff --git a/packages/types/src/definitions/core/Rundown.type.ts b/packages/types/src/definitions/core/Rundown.type.ts index 63a08db701..69fd3268fb 100644 --- a/packages/types/src/definitions/core/Rundown.type.ts +++ b/packages/types/src/definitions/core/Rundown.type.ts @@ -1,7 +1,17 @@ -import type { OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; +import type { EntryId, OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; -export type OntimeRundownEntry = OntimeDelay | OntimeBlock | OntimeEvent; -export type OntimeRundown = OntimeRundownEntry[]; +export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent; +export type RundownEntries = Record; // we need to create a manual union type since keys cannot be used in type unions export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock; + +export type ProjectRundowns = Record; + +export type Rundown = { + id: string; + title: string; + order: EntryId[]; + entries: RundownEntries; + revision: number; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7087d40f30..ce19a5d96e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,6 +4,7 @@ export type { DatabaseModel } from './definitions/DataModel.type.js'; // ---> Rundown export { EndAction } from './definitions/EndAction.type.js'; export { + type EntryId, type OntimeBaseEvent, type OntimeDelay, type OntimeBlock, @@ -12,7 +13,13 @@ export { type TimeField, SupportedEvent, } from './definitions/core/OntimeEvent.type.js'; -export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js'; +export type { + OntimeEntryCommonKeys, + OntimeEntry, + RundownEntries, + Rundown, + ProjectRundowns, +} from './definitions/core/Rundown.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; @@ -53,7 +60,7 @@ export type { CustomFields, CustomField, CustomFieldLabel, - EventCustomFields, + EntryCustomFields, } from './definitions/core/CustomFields.type.js'; // SERVER RESPONSES @@ -73,9 +80,8 @@ export type { export type { QuickStartData } from './api/db/db.type.js'; export type { EventPostPayload, - NormalisedRundown, PatchWithId, - RundownCached, + ProjectRundownsList, TransientEventPayload, } from './api/rundown-controller/BackendResponse.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 552cd24659..4403f89b03 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,10 +1,10 @@ import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js'; import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; -import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; +import type { OntimeEntry } from '../definitions/core/Rundown.type.js'; import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js'; -type MaybeEvent = OntimeRundownEntry | Partial | null | undefined; +type MaybeEvent = OntimeEntry | Partial | null | undefined; export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { return event?.type === SupportedEvent.Event; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 06ec95a5d7..9ef4e100f9 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,10 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { - filterPlayable, - filterTimedEvents, getEventWithId, - getFirst, getFirstEvent, getFirstEventNormal, getFirstNormal, @@ -66,7 +63,7 @@ export { deepmerge } from './src/externals/deepmerge.js'; // array utils export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js'; // object utils -export { getPropertyFromPath } from './src/common/objectUtils.js'; +export { getPropertyFromPath, isObjectEmpty } from './src/common/objectUtils.js'; // generic utilities export { getErrorMessage } from './src/generic/generic.js'; diff --git a/packages/utils/src/common/arrayUtils.ts b/packages/utils/src/common/arrayUtils.ts index 3209004ca6..13b2701e4b 100644 --- a/packages/utils/src/common/arrayUtils.ts +++ b/packages/utils/src/common/arrayUtils.ts @@ -36,11 +36,8 @@ export function deleteAtIndex(index: number, array: T[]) { /** * Reorders two objects in an array - * @param array - * @param fromIndex - * @param toIndex */ -export function reorderArray(array: T[], fromIndex: number, toIndex: number) { +export function reorderArray(array: T[], fromIndex: number, toIndex: number): T[] { if (fromIndex === toIndex) { return array; // No change needed, return the original array } diff --git a/packages/utils/src/common/objectUtils.ts b/packages/utils/src/common/objectUtils.ts index d9fe04e403..882c56cb09 100644 --- a/packages/utils/src/common/objectUtils.ts +++ b/packages/utils/src/common/objectUtils.ts @@ -16,3 +16,7 @@ export function getPropertyFromPath(path: string, obj: T): unk return result; } + +export function isObjectEmpty(obj: object): boolean { + return Object.keys(obj).length === 0; +} diff --git a/packages/utils/src/cue-utils/cueUtils.test.ts b/packages/utils/src/cue-utils/cueUtils.test.ts index 292ad30b6d..03c8d221c1 100644 --- a/packages/utils/src/cue-utils/cueUtils.test.ts +++ b/packages/utils/src/cue-utils/cueUtils.test.ts @@ -1,4 +1,4 @@ -import type { OntimeDelay, OntimeEvent, OntimeRundown } from 'ontime-types'; +import type { OntimeDelay, OntimeEntry, OntimeEvent, RundownEntries } from 'ontime-types'; import { SupportedEvent } from 'ontime-types'; import { getCueCandidate, getIncrement, sanitiseCue } from './cueUtils.js'; @@ -36,76 +36,83 @@ describe('getIncrement()', () => { describe('getCueCandidate()', () => { describe('in the beginning of the rundown', () => { it('names cue as 1 if next event does not collide', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event }, - { id: '2', cue: '11', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '11', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('1'); }); + it('creates decimal stem if next cue is 1', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('0.1'); }); }); + describe('in the middle of the rundown', () => { it('names cue as an increment if next event has different stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('2'); }); + it('names cue as an increment if next event has different stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter', type: SupportedEvent.Event }, - { + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Interval', type: SupportedEvent.Event, - }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + } as OntimeEntry, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter2'); }); + it('creates decimal stem if next cue has same stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('1.1'); }); + it('creates decimal stem if next cue has same stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter1', type: SupportedEvent.Event }, - { id: '2', cue: 'Presenter2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Presenter2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter1.1'); }); }); + describe('considers edge cases', () => { it('previousEvent might not be a cue', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, - { id: '2', type: SupportedEvent.Delay } as OntimeDelay, - ]; - const cue = getCueCandidate(testRundown, '2'); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + }; + const cue = getCueCandidate(entries, ['1', '2'], '2'); expect(cue).toBe('11'); }); }); + it('there might not be events before', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Delay } as OntimeDelay, - { id: '2', type: SupportedEvent.Delay } as OntimeDelay, - ]; - const cue = getCueCandidate(testRundown, '2'); + const entries: RundownEntries = { + '1': { id: '1', type: SupportedEvent.Delay } as OntimeDelay, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + }; + const cue = getCueCandidate(entries, ['1', '2'], '2'); expect(cue).toBe('1'); }); }); @@ -113,53 +120,58 @@ describe('getCueCandidate()', () => { describe('findCueName() with mixed events', () => { describe('in the beginning of the rundown', () => { it('names cue as 1 if next event does not collide', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event }, - { id: '2', cue: '11', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '11', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('1'); }); + it('creates decimal stem if next cue is 1', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('0.1'); }); }); + describe('in the middle of the rundown', () => { it('names cue as an increment if next event has different stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('2'); }); + it('names cue as an increment if next event has different stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter', type: SupportedEvent.Event }, - { id: '2', cue: 'Interval', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Interval', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter2'); }); + it('creates decimal stem if next cue has same stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('1.1'); }); + it('creates decimal stem if next cue has same stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter1', type: SupportedEvent.Event }, - { id: '2', cue: 'Presenter2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Presenter2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter1.1'); }); }); diff --git a/packages/utils/src/cue-utils/cueUtils.ts b/packages/utils/src/cue-utils/cueUtils.ts index 7571bf4248..632b148403 100644 --- a/packages/utils/src/cue-utils/cueUtils.ts +++ b/packages/utils/src/cue-utils/cueUtils.ts @@ -1,7 +1,7 @@ -import type { OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import type { EntryId, OntimeEntry, RundownEntries } from 'ontime-types'; import { isOntimeEvent } from 'ontime-types'; -import { getFirstEvent, getNextEvent, getPreviousEvent } from '../rundown-utils/rundownUtils.js'; +import { getFirstEventNormal, getNextEventNormal, getPreviousEventNormal } from '../rundown-utils/rundownUtils.js'; import { isNumeric } from '../types/types.js'; // Zero or more non-digit characters at the beginning ((\D*)). @@ -11,7 +11,6 @@ const regex = /^(\D*)(\d+)(\.\d+)?$/; /** * Finds if last characters in input are a number and increments - * @param input {string} */ export function getIncrement(input: string): string { // Check if the input string contains a number at the end @@ -41,55 +40,57 @@ export function getIncrement(input: string): string { /** * Gets suitable name for a new event cue - * @param rundown {OntimeRundown} - * @param insertAfterId {string} */ -export function getCueCandidate(rundown: OntimeRundown, insertAfterId?: string): string { - function addAtTop() { - const firstEventCue = getFirstEvent(rundown).firstEvent?.cue; - - if (isNumeric(firstEventCue)) { - return (Number(firstEventCue) / 10).toString(); - } - return '1'; - } - +export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId?: string): string { // we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1 - if (typeof insertAfterId === 'undefined' || rundown.length === 0) { + if (insertAfterId === undefined || order.length === 0) { return addAtTop(); } - const afterIndex = rundown.findIndex((event) => event.id === insertAfterId); + // get the given event, or any before that + let previousEvent: OntimeEntry | null | undefined = entries[insertAfterId]; - // we did not find the previous element, insert at top - if (afterIndex === -1) { - return addAtTop(); + if (!isOntimeEvent(previousEvent)) { + previousEvent = getPreviousEventNormal(entries, order, insertAfterId).previousEvent; + if (!isOntimeEvent(previousEvent)) { + return addAtTop(); + } } - // get elements around - let previousEvent: OntimeRundownEntry | undefined | null | OntimeEvent = rundown.at(afterIndex); - if (!isOntimeEvent(previousEvent)) { - previousEvent = getPreviousEvent(rundown, insertAfterId).previousEvent as null | OntimeEvent; + // the cue is based on the previous event cue + const cue = getIncrement(previousEvent.cue); + const { nextEvent } = getNextEventNormal(entries, order, insertAfterId); + + // if increment is clashing with next, we add a decimal instead + if (cue !== nextEvent?.cue) { + return cue; + } + + // there is a clash, bt the cue is a pure number + if (isNumeric(cue)) { + return incrementDecimal(previousEvent.cue); } - let cue = '1'; - const { nextEvent } = getNextEvent(rundown, insertAfterId); + /** + * at this point, we know the cue is not numeric + * but the increment failed, so we have a numeric ending + * eg. Presenter 1 .... Presenter 2 -> Presenter1.1 + * eg. Presenter 1.1 .... Presenter 1.2 -> Presenter1.1.1 + */ + return `${previousEvent.cue}.1`; - // try and increment the cue - if (isOntimeEvent(previousEvent)) { - cue = getIncrement(previousEvent.cue); + function incrementDecimal(cue: string) { + const n = Number(cue); + return (n + 0.1).toString(); } - // if increment is clashing with next, we add a decimal instead - if (cue === nextEvent?.cue) { - if (previousEvent === null) { - cue = '0.1'; - } else { - cue = `${previousEvent.cue}.1`; + function addAtTop() { + const firstEventCue = getFirstEventNormal(entries, order).firstEvent?.cue; + if (firstEventCue === '1') { + return '0.1'; } + return '1'; } - - return cue; } export function sanitiseCue(cue: string) { diff --git a/packages/utils/src/date-utils/checkIsNextDay.test.ts b/packages/utils/src/date-utils/checkIsNextDay.test.ts index 9abbee8528..235ba8f248 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.test.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.test.ts @@ -4,7 +4,7 @@ import { MILLIS_PER_HOUR } from './conversionUtils'; describe('checkIsNextDay', () => { it('returns false if there is no previous event', () => { const current = { timeStart: 0, dayOffset: 0 }; - const previous = undefined; + const previous = null; expect(checkIsNextDay(current, previous)).toBeFalsy(); }); diff --git a/packages/utils/src/date-utils/checkIsNextDay.ts b/packages/utils/src/date-utils/checkIsNextDay.ts index 71a629ba68..8b4476ae0e 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function checkIsNextDay( current: Pick, - previous?: Pick, + previous: Pick | null, ): boolean { if (!previous) { return false; diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.ts b/packages/utils/src/date-utils/getTimeFromPrevious.ts index f9227c18ac..4f22b35d5b 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.ts +++ b/packages/utils/src/date-utils/getTimeFromPrevious.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function getTimeFromPrevious( current: Pick, - previous?: Pick, + previous: Pick | null, ): number { // there is no previous event if (!previous) { diff --git a/packages/utils/src/date-utils/isNewLatest.test.ts b/packages/utils/src/date-utils/isNewLatest.test.ts index 3dbef16b38..5629155505 100644 --- a/packages/utils/src/date-utils/isNewLatest.test.ts +++ b/packages/utils/src/date-utils/isNewLatest.test.ts @@ -4,7 +4,7 @@ import { isNewLatest } from './isNewLatest'; describe('isNewLatest', () => { it('should be true if there is no previous', () => { const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 }; - const previous = undefined; + const previous = null; expect(isNewLatest(current, previous)).toBe(true); }); diff --git a/packages/utils/src/date-utils/isNewLatest.ts b/packages/utils/src/date-utils/isNewLatest.ts index 7bdcfa3420..a5c6bcd7ac 100644 --- a/packages/utils/src/date-utils/isNewLatest.ts +++ b/packages/utils/src/date-utils/isNewLatest.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function isNewLatest( currentEvent: Pick, - previousEvent?: Pick, + previousEvent: Pick | null, ) { // true if there is no previous if (!previousEvent) { diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index bafa7f52b8..b25522130d 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -1,8 +1,7 @@ -import type { NormalisedRundown, OntimeEvent, OntimeRundown } from 'ontime-types'; +import type { OntimeBlock, OntimeDelay, OntimeEntry, OntimeEvent } from 'ontime-types'; import { SupportedEvent } from 'ontime-types'; import { - filterPlayable, getLastEvent, getLastNormal, getNext, @@ -15,36 +14,46 @@ import { describe('getNext()', () => { it('returns the next event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; - - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '1'); + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + + const { nextEvent, nextIndex } = getNext(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); - it('alows other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '1'); + it('returns any type of OntimeEntry ', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; + + const { nextEvent, nextIndex } = getNext(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); - it('returns null if none found', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - ]; - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '3'); + it('returns null if none found', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + const { nextEvent, nextIndex } = getNext(testRundown, '3'); expect(nextEvent).toBe(null); expect(nextIndex).toBe(null); }); @@ -53,35 +62,37 @@ describe('getNext()', () => { describe('getNextEvent()', () => { it('returns the next event of type event', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Event } as OntimeEvent, + { id: '3', type: SupportedEvent.Event } as OntimeEvent, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); + it('ignores other event types', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Block } as OntimeBlock, + { id: '4', type: SupportedEvent.Event } as OntimeEvent, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent?.id).toBe('4'); expect(nextIndex).toBe(3); }); + it('returns null if none found', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Block } as OntimeBlock, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent).toBe(null); expect(nextIndex).toBe(null); }); @@ -89,36 +100,46 @@ describe('getNextEvent()', () => { describe('getPrevious()', () => { it('returns the previous event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; - - const { entry, index } = getPrevious(testRundown as OntimeRundown, '3'); + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + + const { entry, index } = getPrevious(testRundown, '3'); expect(entry?.id).toBe('2'); expect(index).toBe(1); }); - it('allow other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { entry, index } = getPrevious(testRundown as OntimeRundown, '3'); + it('allow other event types', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; + + const { entry, index } = getPrevious(testRundown, '3'); expect(entry?.id).toBe('2'); expect(index).toBe(1); }); - it('returns null if none found', () => { - const testRundown = [ - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { entry, index } = getPrevious(testRundown as OntimeRundown, '2'); + it('returns null if none found', () => { + const testRundown = { + entries: { + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + + const { entry, index } = getPrevious(testRundown, '2'); expect(entry).toBe(null); expect(index).toBe(null); }); @@ -126,36 +147,47 @@ describe('getPrevious()', () => { describe('getPreviousEvent()', () => { it('returns the previous event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; - - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '3'); + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '3'); expect(previousEvent?.id).toBe('2'); expect(previousIndex).toBe(1); }); - it('ignores other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '4'); + it('ignores other event types', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; + + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '4'); expect(previousEvent?.id).toBe('1'); expect(previousIndex).toBe(0); }); - it('returns null if none found', () => { - const testRundown = [ - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '2'); + it('returns null if none found', () => { + const testRundown = { + entries: { + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['2', '3', '4'], + }; + + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '2'); expect(previousEvent).toBe(null); expect(previousIndex).toBe(null); }); @@ -170,6 +202,7 @@ describe('swapEventData', () => { timeEnd: 1, duration: 1, delay: 1, + revision: 3, } as OntimeEvent; const eventB = { id: '2', @@ -178,9 +211,10 @@ describe('swapEventData', () => { timeEnd: 2, duration: 2, delay: 2, + revision: 7, } as OntimeEvent; - const { newA, newB } = swapEventData(eventA, eventB); + const [newA, newB] = swapEventData(eventA, eventB); expect(newA).toMatchObject({ id: '1', @@ -189,6 +223,7 @@ describe('swapEventData', () => { timeEnd: 1, duration: 1, delay: 1, + revision: 4, }); expect(newB).toMatchObject({ id: '2', @@ -197,121 +232,119 @@ describe('swapEventData', () => { timeEnd: 2, duration: 2, delay: 2, + revision: 8, }); }); }); describe('getLastEvent', () => { it('returns the last event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Event }, - { id: '4', type: SupportedEvent.Block }, + const testRundown: OntimeEntry[] = [ + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Event } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, ]; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + const { lastEvent } = getLastEvent(testRundown); expect(lastEvent?.id).toBe('3'); }); - it('handles rundowns with a single event', () => { - const testRundown = [{ id: '1', type: SupportedEvent.Event }]; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + it('handles rundowns with a single event', () => { + const testRundown: OntimeEntry[] = [{ id: '1', type: SupportedEvent.Event } as OntimeEvent]; + const { lastEvent } = getLastEvent(testRundown); expect(lastEvent?.id).toBe('1'); }); describe('getLastNormal', () => { it('returns the last entry', () => { - const testRundown = { - 4: { id: '4', type: SupportedEvent.Block }, - 1: { id: '1', type: SupportedEvent.Event }, - 3: { id: '3', type: SupportedEvent.Event }, - 2: { id: '2', type: SupportedEvent.Delay }, + const entries = { + 4: { id: '4', type: SupportedEvent.Block } as OntimeBlock, + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + 3: { id: '3', type: SupportedEvent.Event } as OntimeEvent, + 2: { id: '2', type: SupportedEvent.Delay } as OntimeDelay, }; const order = ['1', '2', '3', '4']; - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal(entries, order); expect(lastEntry?.id).toBe('4'); }); + it('handles rundowns with a single event', () => { - const testRundown = [{ id: '1', type: SupportedEvent.Event }]; + const entries = { + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + }; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + const lastEvent = getLastNormal(entries, ['1']); expect(lastEvent?.id).toBe('1'); }); it('handles empty order', () => { const testRundown = { - 4: { id: '4', type: SupportedEvent.Block }, - 1: { id: '1', type: SupportedEvent.Event }, - 3: { id: '3', type: SupportedEvent.Event }, - 2: { id: '2', type: SupportedEvent.Delay }, + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + 2: { id: '2', type: SupportedEvent.Event } as OntimeEvent, }; - const order: string[] = []; - - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal(testRundown, []); expect(lastEntry).toBe(null); }); it('handles empty rundown', () => { - const testRundown = {}; - - const order = ['1', '2', '3', '4']; - - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal({}, ['1', '2', '3', '4']); expect(lastEntry).toBe(null); }); }); - describe('relevantBlock', () => { - const testRundown = [ - { id: 'a', type: SupportedEvent.Event }, - { id: 'b', type: SupportedEvent.Event }, - { id: 'c', type: SupportedEvent.Event }, - { id: 'd', type: SupportedEvent.Delay }, - { id: 'e', type: SupportedEvent.Block }, - { id: 'f', type: SupportedEvent.Event }, - { id: 'g', type: SupportedEvent.Block }, - { id: 'h', type: SupportedEvent.Event }, - ]; - - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'h'); - - expect(block?.id).toBe('g'); + describe('getPreviousBlock()', () => { + const testRundown = { + entries: { + a: { id: 'a', type: SupportedEvent.Event } as OntimeEvent, + b: { id: 'b', type: SupportedEvent.Event } as OntimeEvent, + c: { id: 'c', type: SupportedEvent.Event } as OntimeEvent, + d: { id: 'd', type: SupportedEvent.Delay } as OntimeDelay, + e: { id: 'e', type: SupportedEvent.Block } as OntimeBlock, + f: { id: 'f', type: SupportedEvent.Event } as OntimeEvent, + g: { id: 'g', type: SupportedEvent.Block } as OntimeBlock, + h: { id: 'h', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + }; + + test.each([ + ['h', 'g'], + ['f', 'e'], + ])('returns the relevant block', (id, expected) => { + const block = getPreviousBlock(testRundown, id); + expect(block?.id).toBe(expected); }); - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'f'); - expect(block?.id).toBe('e'); + it('returns null if there is no parent block relevant block', () => { + const block = getPreviousBlock(testRundown, 'a'); + expect(block).toBe(null); }); - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); - expect(block).toBeNull(); - }); it('also works on index 0', () => { - testRundown.unshift({ id: '0', type: SupportedEvent.Block }); - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); + testRundown.order.unshift('0'); + // @ts-expect-error -- we are adding an event to the rundown + testRundown.entries['0'] = { id: '0', type: SupportedEvent.Block } as OntimeBlock; + const block = getPreviousBlock(testRundown, 'a'); expect(block?.id).toBe('0'); }); - }); - describe('filterPlayable()', () => { - test('should return an array with only playable events', () => { - const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent; - const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent; - const testRundown = [ - eventA, - eventB, - { id: 'c', type: SupportedEvent.Delay }, - { id: 'd', type: SupportedEvent.Block }, - ]; - - const result = filterPlayable(testRundown as unknown as OntimeRundown); - expect(result).toMatchObject([eventA]); + it('returns the parent block if nested event', () => { + const testRundown = { + entries: { + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + block: { id: 'block', type: SupportedEvent.Block, events: ['21', '22', '23'] } as OntimeBlock, + 21: { id: '21', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + 22: { id: '22', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + 23: { id: '23', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + }, + order: ['1', 'block'], + }; + const block = getPreviousBlock(testRundown, '21'); + expect(block?.id).toBe('block'); }); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 622db1aa54..df7f77f97e 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,37 +1,31 @@ import type { - NormalisedRundown, + EntryId, OntimeBlock, + OntimeEntry, OntimeEvent, - OntimeRundown, - OntimeRundownEntry, PlayableEvent, + Rundown, + RundownEntries, } from 'ontime-types'; import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types'; -type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null }; - -/** - * Gets first event in rundown, if it exists - */ -export function getFirst(rundown: OntimeRundown) { - return rundown.length ? rundown[0] : null; -} +type IndexAndEntry = { entry: OntimeEntry | null; index: number | null }; /** * Gets first event in a normalised rundown, if it exists - * @param rundown - * @param order - * @returns */ -export function getFirstNormal(rundown: NormalisedRundown, order: string[]) { - const firstId = order[0]; - return rundown[firstId] ?? null; +export function getFirstNormal(entries: RundownEntries, order: EntryId[]): OntimeEntry | null { + if (!order.length) { + return null; + } + const eventId = order[0]; + return entries[eventId] ?? null; } /** * Gets first scheduled event in rundown, if it exists */ -export function getFirstEvent(rundown: OntimeRundown): { +export function getFirstEvent(rundown: OntimeEntry[]): { firstEvent: PlayableEvent | null; firstIndex: number | null; } { @@ -48,8 +42,8 @@ export function getFirstEvent(rundown: OntimeRundown): { * Gets first scheduled event in a normalised rundown, if it exists */ export function getFirstEventNormal( - rundown: NormalisedRundown, - order: string[], + rundown: RundownEntries, + order: EntryId[], ): { firstEvent: PlayableEvent | null; firstIndex: number | null; @@ -67,18 +61,18 @@ export function getFirstEventNormal( /** * Gets last event in a normalised rundown, if it exists */ -export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null { +export function getLastNormal(entries: RundownEntries, order: EntryId[]): OntimeEntry | null { const lastId = order.at(-1); if (lastId === undefined) { return null; } - return rundown[lastId] ?? null; + return entries[lastId] ?? null; } /** * Gets last scheduled event in rundown, if it exists */ -export function getLastEvent(rundown: OntimeRundown): { +export function getLastEvent(rundown: OntimeEntry[]): { lastEvent: PlayableEvent | null; lastIndex: number | null; } { @@ -87,7 +81,7 @@ export function getLastEvent(rundown: OntimeRundown): { } for (let i = rundown.length - 1; i >= 0; i--) { - const lastEvent = rundown.at(i); + const lastEvent = rundown[i]; if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) { return { lastEvent, lastIndex: i }; } @@ -99,7 +93,7 @@ export function getLastEvent(rundown: OntimeRundown): { * Gets last scheduled event in a normalised rundown, if it exists */ export function getLastEventNormal( - rundown: NormalisedRundown, + rundown: RundownEntries, order: string[], ): { lastEvent: OntimeEvent | null; @@ -123,13 +117,14 @@ export function getLastEventNormal( * Gets next entry in rundown, if it exists */ export function getNext( - rundown: OntimeRundown, + rundown: Pick, currentId: string, -): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); - if (index !== -1 && index + 1 < rundown.length) { +): { nextEvent: OntimeEntry | null; nextIndex: number | null } { + const index = rundown.order.findIndex((entryId) => entryId === currentId); + if (index !== -1 && index + 1 < rundown.order.length) { const nextIndex = index + 1; - const nextEvent = rundown[nextIndex]; + const nextId = rundown.order[nextIndex]; + const nextEvent = rundown.entries[nextId]; return { nextEvent, nextIndex }; } else { return { nextEvent: null, nextIndex: null }; @@ -139,7 +134,7 @@ export function getNext( /** * Gets next entry in rundown, if it exists */ -export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getNextNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); if (currentIndex !== -1 && currentIndex + 1 < order.length) { const index = currentIndex + 1; @@ -155,10 +150,10 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre * Gets next scheduled event in rundown, if it exists */ export function getNextEvent( - rundown: OntimeRundown, + rundown: OntimeEntry[], currentId: string, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); + const index = rundown.findIndex((entry) => entry.id === currentId); if (index < 0) { return { nextEvent: null, nextIndex: null }; } @@ -176,18 +171,18 @@ export function getNextEvent( * Gets next scheduled event in a normalised rundown, if it exists */ export function getNextEventNormal( - rundown: NormalisedRundown, - order: string[], + entries: RundownEntries, + order: EntryId[], currentId: string, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { - const index = order.findIndex((id) => id === currentId); + const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { nextEvent: null, nextIndex: null }; } for (let i = index + 1; i < order.length; i++) { const nextId = order[i]; - const nextEvent = rundown[nextId]; + const nextEvent = entries[nextId]; if (isOntimeEvent(nextEvent)) { return { nextEvent, nextIndex: i }; } @@ -198,11 +193,13 @@ export function getNextEventNormal( /** * Gets previous entry in rundown, if it exists */ -export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry { - const currentIndex = rundown.findIndex((event) => event.id === currentId); - if (currentIndex !== -1 && currentIndex - 1 >= 0) { +export function getPrevious(rundown: Pick, currentId: string): IndexAndEntry { + const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId); + + if (currentIndex > 1) { const index = currentIndex - 1; - const entry = rundown[index]; + const previousId = rundown.order[index]; + const entry = rundown.entries[previousId]; return { entry, index }; } else { return { entry: null, index: null }; @@ -210,14 +207,15 @@ export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAnd } /** - * Gets previous entry in a nornalised rundown, if it exists + * Gets previous entry in a normalised rundown, if it exists */ -export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getPreviousNormal(entries: RundownEntries, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); + if (currentIndex !== -1 && currentIndex - 1 >= 0) { const index = currentIndex - 1; const previousId = order[index]; - const entry = rundown[previousId]; + const entry = entries[previousId]; return { entry, index }; } else { return { entry: null, index: null }; @@ -228,15 +226,16 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c * Gets previous scheduled event in rundown, if it exists */ export function getPreviousEvent( - rundown: OntimeRundown, + rundown: Pick, currentId: string, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); + const index = rundown.order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { previousEvent: null, previousIndex: null }; } for (let i = index - 1; i >= 0; i--) { - const previousEvent = rundown[i]; + const previousId = rundown.order[i]; + const previousEvent = rundown.entries[previousId]; if (isOntimeEvent(previousEvent)) { return { previousEvent, previousIndex: i }; } @@ -246,23 +245,19 @@ export function getPreviousEvent( /** * Gets previous scheduled event in a normalised rundown, if it exists - * @param rundown - * @param order - * @param {string} currentId - * @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } } */ export function getPreviousEventNormal( - rundown: NormalisedRundown, - order: string[], + entries: RundownEntries, + order: EntryId[], currentId: string, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { - const index = order.findIndex((id) => id === currentId); + const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { previousEvent: null, previousIndex: null }; } for (let i = index - 1; i >= 0; i--) { const previousId = order[i]; - const previousEvent = rundown[previousId]; + const previousEvent = entries[previousId]; if (isOntimeEvent(previousEvent)) { return { previousEvent, previousIndex: i }; } @@ -273,7 +268,7 @@ export function getPreviousEventNormal( /** * @description swaps two OntimeEvents in the rundown */ -export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => { +export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: OntimeEvent, newB: OntimeEvent] => { const newA = { ...eventB, // events keep the ID @@ -304,17 +299,17 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: dayOffset: eventB.dayOffset, }; - return { newA, newB }; + return [newA, newB]; }; -export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined { +export function getEventWithId(rundown: OntimeEntry[], id: string): OntimeEntry | undefined { return rundown.find((event) => event.id === id); } /** * Gets relevant block element for a given ID */ -export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getPreviousBlockNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event for (let index = order.length - 1; index >= 0; index--) { @@ -337,7 +332,7 @@ export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string /** * Gets next block element for a given ID */ -export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getNextBlockNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event for (let index = 0; index < order.length; index++) { @@ -360,11 +355,19 @@ export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], /** * Gets relevant block element for a given ID */ -export function getPreviousBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { +export function getPreviousBlock(rundown: Pick, currentId: EntryId): OntimeBlock | null { + const currentEvent = rundown.entries[currentId]; + + // check if event is inside a block + if (isOntimeEvent(currentEvent) && currentEvent.currentBlock) { + return rundown.entries[currentEvent.currentBlock] as OntimeBlock; + } + let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event - for (let i = rundown.length - 1; i >= 0; i--) { - const entry = rundown[i]; + for (let i = rundown.order.length - 1; i >= 0; i--) { + const entryId = rundown.order[i]; + const entry = rundown.entries[entryId]; if (!foundCurrentEvent && entry.id === currentId) { // set the flag when the current event is found foundCurrentEvent = true; @@ -375,20 +378,6 @@ export function getPreviousBlock(rundown: OntimeRundown, currentId: string): Ont return entry; } } - // no blocks exist before current event + // no blocks exist before null event return null; } - -/** - * filters a rundown to timed events - */ -export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] { - return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[]; -} - -/** - * filters a rundown to events that can be played - */ -export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] { - return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[]; -} diff --git a/playwright.config.ts b/playwright.config.ts index 547c7ca320..d6fac572e6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ const config: PlaywrightTestConfig = { workers: 1, reporter: 'html', webServer: { - command: 'turbo run dev:test', + command: 'turbo run dev --filter=ontime-server', port: 4001, reuseExistingServer: true, timeout: 60 * 1000, From 7287cf851124dd08b55dfcf98a572f7a144ec847 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 23 Mar 2025 22:01:33 +0100 Subject: [PATCH 04/49] refactor: remove stop as a possible end action --- .../app-settings/panel/interface-panel/EditorSettingsForm.tsx | 1 - .../src/features/rundown/event-block/EventBlockInner.tsx | 4 ---- .../rundown/event-editor/composite/EventEditorTimes.tsx | 1 - apps/server/src/services/runtime-service/RuntimeService.ts | 4 +--- packages/types/src/definitions/EndAction.type.ts | 1 - packages/utils/src/validate-events/validateEvent.test.ts | 4 ++-- 6 files changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx index f6bcd00f6e..8772a129ee 100644 --- a/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/interface-panel/EditorSettingsForm.tsx @@ -102,7 +102,6 @@ export default function EditorSettingsForm() { onChange={(event) => setDefaultEndAction(event.target.value as EndAction)} > - diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index c404ded561..ce821f33be 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -8,7 +8,6 @@ import { IoPlay, IoPlayForward, IoPlaySkipForward, - IoStop, IoTime, } from 'react-icons/io5'; import { Tooltip } from '@chakra-ui/react'; @@ -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-editor/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx index f5a5f8b463..cd9593cc68 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -114,7 +114,6 @@ function EventEditorTimes(props: EventEditorTimesProps) { variant='ontime' > - diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 071e0b976b..2883f260ac 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -134,9 +134,7 @@ class RuntimeService { // handle end action if there was a timer playing // actions are added to the queue stack to ensure that the order of operations is maintained if (newState.eventNow) { - if (newState.eventNow.endAction === EndAction.Stop) { - setTimeout(this.stop.bind(this), 0); - } else if (newState.eventNow.endAction === EndAction.LoadNext) { + if (newState.eventNow.endAction === EndAction.LoadNext) { setTimeout(this.loadNext.bind(this), 0); } else if (newState.eventNow.endAction === EndAction.PlayNext) { setTimeout(this.startNext.bind(this), 0); diff --git a/packages/types/src/definitions/EndAction.type.ts b/packages/types/src/definitions/EndAction.type.ts index 7db64a69b6..81111c79c6 100644 --- a/packages/types/src/definitions/EndAction.type.ts +++ b/packages/types/src/definitions/EndAction.type.ts @@ -2,5 +2,4 @@ export enum EndAction { LoadNext = 'load-next', None = 'none', PlayNext = 'play-next', - Stop = 'stop', } diff --git a/packages/utils/src/validate-events/validateEvent.test.ts b/packages/utils/src/validate-events/validateEvent.test.ts index aa61b4106e..9c8734d14e 100644 --- a/packages/utils/src/validate-events/validateEvent.test.ts +++ b/packages/utils/src/validate-events/validateEvent.test.ts @@ -9,9 +9,9 @@ describe('validateEndAction()', () => { expect(endAction).toBe(EndAction.LoadNext); }); it('returns fallback otherwise', () => { - const emptyAction = validateEndAction('', EndAction.Stop); + const emptyAction = validateEndAction('', EndAction.LoadNext); const invalidAction = validateEndAction('this-does-not-exist', EndAction.PlayNext); - expect(emptyAction).toBe(EndAction.Stop); + expect(emptyAction).toBe(EndAction.LoadNext); expect(invalidAction).toBe(EndAction.PlayNext); }); }); From 6b01338118cd7f91b31ad981ac4cc1694304bc31 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 21 Mar 2025 19:44:16 +0100 Subject: [PATCH 05/49] refactor: use strict typing --- apps/server/package.json | 1 + apps/server/src/adapters/WebsocketAdapter.ts | 25 +++-- apps/server/src/adapters/utils/parse.ts | 4 +- apps/server/src/api-data/db/db.controller.ts | 2 +- .../src/api-data/excel/excel.controller.ts | 3 +- .../src/api-data/report/report.service.ts | 1 - .../src/api-data/session/session.service.ts | 6 +- .../src/api-data/sheets/sheets.controller.ts | 7 +- .../src/api-data/sheets/sheets.validation.ts | 4 + .../url-presets/urlPresets.controller.ts | 2 +- .../src/api-integration/integration.router.ts | 3 +- apps/server/src/app.ts | 2 +- .../src/classes/simple-timer/SimpleTimer.ts | 6 ++ apps/server/src/config/config.ts | 9 -- apps/server/src/middleware/authenticate.ts | 4 +- apps/server/src/services/EventTimer.ts | 12 ++- .../app-state-service/AppStateService.ts | 2 - .../aux-timer-service/AuxTimerService.ts | 5 +- .../message-service/MessageService.ts | 3 +- .../__tests__/MessageService.test.ts | 12 ++- .../project-service/ProjectService.ts | 35 ++++-- .../__tests__/ProjectService.test.ts | 5 - .../runtime-service/RuntimeService.ts | 12 +-- .../runtime-service/rundownService.utils.ts | 4 +- .../services/sheet-service/SheetService.ts | 101 ++++++++++++------ .../services/sheet-service/googleApi.utils.ts | 39 +++++++ .../src/services/sheet-service/sheetUtils.ts | 32 +++--- apps/server/src/setup/config.ts | 10 ++ apps/server/src/stores/runtimeState.ts | 15 ++- .../server/src/utils/__tests__/assert.test.ts | 34 ++++++ .../server/src/utils/__tests__/parser.test.ts | 7 +- .../src/utils/__tests__/parserUtils.test.ts | 88 +-------------- apps/server/src/utils/assert.ts | 18 ++-- apps/server/src/utils/fileManagement.ts | 2 +- apps/server/src/utils/is.ts | 10 ++ apps/server/src/utils/parser.ts | 18 +++- apps/server/src/utils/parserUtils.ts | 28 ----- apps/server/tsconfig.json | 2 +- pnpm-lock.yaml | 12 +++ 39 files changed, 339 insertions(+), 246 deletions(-) delete mode 100644 apps/server/src/config/config.ts create mode 100644 apps/server/src/services/sheet-service/googleApi.utils.ts create mode 100644 apps/server/src/utils/__tests__/assert.test.ts create mode 100644 apps/server/src/utils/is.ts diff --git a/apps/server/package.json b/apps/server/package.json index cf27f361ed..6f50b0606f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,6 +26,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 849c0884a1..fd8a91e1d1 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -102,7 +102,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 5bb18118d2..42e888b9b8 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -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/excel/excel.controller.ts b/apps/server/src/api-data/excel/excel.controller.ts index 4d1436fe41..16d2e50722 100644 --- a/apps/server/src/api-data/excel/excel.controller.ts +++ b/apps/server/src/api-data/excel/excel.controller.ts @@ -9,7 +9,8 @@ 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) { diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index 8cdd75f551..c453d9509f 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -60,6 +60,5 @@ export function triggerReportEntry( sendRefetch({ target: 'REPORT', }); - return; } } 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 b82935b51b..59a86636ed 100644 --- a/apps/server/src/api-data/sheets/sheets.controller.ts +++ b/apps/server/src/api-data/sheets/sheets.controller.ts @@ -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 */ } 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/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/app.ts b/apps/server/src/app.ts index 1055e3a1ea..423e782acc 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'); diff --git a/apps/server/src/classes/simple-timer/SimpleTimer.ts b/apps/server/src/classes/simple-timer/SimpleTimer.ts index 8ae23cb464..15df69b693 100644 --- a/apps/server/src/classes/simple-timer/SimpleTimer.ts +++ b/apps/server/src/classes/simple-timer/SimpleTimer.ts @@ -86,6 +86,12 @@ export class SimpleTimer { public update(timeNow: number): SimpleTimerState { if (this.state.playback === SimplePlayback.Start) { // we know startedAt is not null since we are in play mode + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (this.startedAt === null) { + throw new Error('SimpleTimer.update: invalid state received'); + } + } const elapsed = timeNow - this.startedAt; if (this.state.direction === SimpleDirection.CountDown) { this.state.current = this.state.duration - elapsed; diff --git a/apps/server/src/config/config.ts b/apps/server/src/config/config.ts deleted file mode 100644 index 80778d4682..0000000000 --- a/apps/server/src/config/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { MILLIS_PER_MINUTE } from 'ontime-utils'; - -export const timerConfig = { - skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight - updateRate: 32, // how often do we update the timer - notificationRate: 1000, // how often do we notify clients and integrations - triggerAhead: 10, // how far ahead do we trigger the end event - auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration -}; diff --git a/apps/server/src/middleware/authenticate.ts b/apps/server/src/middleware/authenticate.ts index 30e81012fc..c070a607dd 100644 --- a/apps/server/src/middleware/authenticate.ts +++ b/apps/server/src/middleware/authenticate.ts @@ -81,7 +81,9 @@ export function makeAuthenticateMiddleware(prefix: string) { // we use query params for generating authenticated URLs and for clients like the companion module // if the user gives is a token in the query params, we set the cookie to be used in further requests if (req.query.token === hashedPassword) { - setSessionCookie(res, hashedPassword); + if (hashedPassword !== undefined) { + setSessionCookie(res, hashedPassword); + } return next(); } diff --git a/apps/server/src/services/EventTimer.ts b/apps/server/src/services/EventTimer.ts index e40aa67a63..98eac31077 100644 --- a/apps/server/src/services/EventTimer.ts +++ b/apps/server/src/services/EventTimer.ts @@ -1,11 +1,11 @@ +import { timerConfig } from '../setup/config.js'; import * as runtimeState from '../stores/runtimeState.js'; import type { UpdateResult } from '../stores/runtimeState.js'; -import { timerConfig } from '../config/config.js'; type UpdateCallbackFn = (updateResult: UpdateResult) => void; /** - * Service manages Ontime's main timer + * Manages Ontime's main timer */ export class EventTimer { private readonly _interval: NodeJS.Timeout; @@ -43,6 +43,14 @@ export class EventTimer { } const state = runtimeState.getState(); + + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.timer.current === null) { + throw new Error('EventTimer.start: invalid state received'); + } + } + const endTime = state.timer.current - timerConfig.triggerAhead; this.endCallback = setTimeout(() => this.update(), endTime); return true; diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index 1f24300997..f97dadf8dd 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -45,8 +45,6 @@ export async function getShowWelcomeDialog(): Promise { } export async function setShowWelcomeDialog(show: boolean): Promise { - if (isTest) return; - config.data.showWelcomeDialog = show; await config.write(); return show; diff --git a/apps/server/src/services/aux-timer-service/AuxTimerService.ts b/apps/server/src/services/aux-timer-service/AuxTimerService.ts index 7b04ae4f7d..7e8fe90cb1 100644 --- a/apps/server/src/services/aux-timer-service/AuxTimerService.ts +++ b/apps/server/src/services/aux-timer-service/AuxTimerService.ts @@ -2,7 +2,7 @@ import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types' import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js'; import { eventStore } from '../../stores/EventStore.js'; -import { timerConfig } from '../../config/config.js'; +import { timerConfig } from '../../setup/config.js'; type EmitFn = (state: SimpleTimerState) => void; type GetTimeFn = () => number; @@ -77,7 +77,8 @@ function broadcastReturn(_target: any, _propertyKey: string, descriptor: Propert descriptor.value = function (...args: any[]) { const result = originalMethod.apply(this, args); - this.emit(result); + // @ts-expect-error -- we can access private properties from the decorator + (this as AuxTimerService).emit(result); return result; }; diff --git a/apps/server/src/services/message-service/MessageService.ts b/apps/server/src/services/message-service/MessageService.ts index eec0c3266d..c943cb92be 100644 --- a/apps/server/src/services/message-service/MessageService.ts +++ b/apps/server/src/services/message-service/MessageService.ts @@ -31,7 +31,8 @@ export function clear() { * Exposes the internal state of the message service */ export function getState(): MessageState { - return storeGet('message'); + // we know this exists at runtime + return storeGet('message') as MessageState; } /** diff --git a/apps/server/src/services/message-service/__tests__/MessageService.test.ts b/apps/server/src/services/message-service/__tests__/MessageService.test.ts index 0ef7ef1535..e37fa6910b 100644 --- a/apps/server/src/services/message-service/__tests__/MessageService.test.ts +++ b/apps/server/src/services/message-service/__tests__/MessageService.test.ts @@ -1,12 +1,16 @@ +import { RuntimeStore } from 'ontime-types'; + import * as messageService from '../MessageService.js'; describe('MessageService', () => { + let store: Partial; beforeEach(() => { // at runtime, the store is instantiated before the message service - const store = {}; - const storeSetter = (key, value) => (store[key] = value); - const storeGetter = (key) => store[key]; - messageService.init(storeSetter, storeGetter); + store = {}; + messageService.init( + (key, value) => (store[key] = value), + (key) => store[key], + ); messageService.clear(); }); diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 252e728f91..dfc56281cf 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -42,6 +42,21 @@ import { } from './projectServiceUtils.js'; import { getFirstRundown } from '../rundown-service/rundownUtils.js'; +type ProjectState = + | { + status: 'PENDING'; + currentProjectName: undefined; + } + | { + status: 'INITIALIZED'; + currentProjectName: string; + }; + +let currentProjectState: ProjectState = { + status: 'PENDING', + currentProjectName: undefined, +}; + // init dependencies init(); @@ -53,11 +68,17 @@ function init() { ensureDirectory(publicDir.corruptDir); } -export async function getCurrentProject() { - const filename = await getLastLoadedProject(); - const pathToFile = getPathToProject(filename); +export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { + if (currentProjectState.status === 'PENDING') { + const lastLoadedProject = await initialiseProject(); + currentProjectState = { + status: 'INITIALIZED', + currentProjectName: lastLoadedProject, + }; + } + const pathToFile = getPathToProject(currentProjectState.currentProjectName); - return { filename, pathToFile }; + return { filename: currentProjectState.currentProjectName, pathToFile }; } /** @@ -276,6 +297,9 @@ export async function createProject(filename: string, initialData: Partial ({ * controller depend on these to send the right responses */ describe('deleteProjectFile', () => { - it('throws an error if trying to delete the currently loaded project', async () => { - (isLastLoadedProject as Mock).mockResolvedValue(true); - await expect(deleteProjectFile('loadedProject')).rejects.toThrow('Cannot delete currently loaded project'); - }); - it('throws an error if the project file does not exist', async () => { (isLastLoadedProject as Mock).mockResolvedValue(false); (doesProjectExist as Mock).mockReturnValue(null); diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 2883f260ac..58153b2211 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -18,10 +18,10 @@ import { deepEqual } from 'fast-equals'; import { logger } from '../../classes/Logger.js'; import * as runtimeState from '../../stores/runtimeState.js'; import type { RuntimeState } from '../../stores/runtimeState.js'; -import { timerConfig } from '../../config/config.js'; import { eventStore } from '../../stores/EventStore.js'; - import { triggerReportEntry } from '../../api-data/report/report.service.js'; +import { timerConfig } from '../../setup/config.js'; +import { triggerAutomations } from '../../api-data/automation/automation.service.js'; import { EventTimer } from '../EventTimer.js'; import { RestorePoint, restoreService } from '../RestoreService.js'; @@ -35,12 +35,11 @@ import { getTimedEvents, getRundownData, } from '../rundown-service/rundownUtils.js'; - -import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; import { skippedOutOfEvent } from '../timerUtils.js'; -import { triggerAutomations } from '../../api-data/automation/automation.service.js'; import { getEventOrder } from '../rundown-service/rundownCache.js'; +import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; + type RuntimeStateEventKeys = keyof Pick; /** @@ -48,7 +47,7 @@ type RuntimeStateEventKeys = keyof Pick { /** * Parses and validates a client secret string - * @param clientSecret - * @returns */ export function handleClientSecret(clientSecret: string): ClientSecret { const clientSecretObject = JSON.parse(clientSecret); - try { - validateClientSecret(clientSecretObject); - } catch (error) { - throw new Error(`Client secret is invalid: ${error}`); + if (!isClientSecret(clientSecretObject)) { + throw new Error('Client secret is invalid'); } return clientSecretObject; @@ -94,21 +91,26 @@ type CodesResponse = { }; /** - * Establishes connection with Google Auth server - * and retrieves device codes - * @param clientSecret - * @returns + * Establishes connection with Google Auth server and retrieves device codes */ async function getDeviceCodes(clientSecret: ClientSecret): Promise { - const deviceCodes: CodesResponse = await got - .post(codesUrl, { - json: { - client_id: clientSecret.installed.client_id, - scope: sheetScope, - }, - }) - .json(); + const response = await fetch(codesUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: clientSecret.installed.client_id, + scope: sheetScope, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch device codes: ${response.status} ${response.statusText} - ${errorText}`); + } + const deviceCodes: CodesResponse = await response.json(); return deviceCodes; } @@ -186,6 +188,9 @@ function verifyConnection( } export function hasAuth(): { authenticated: AuthenticationStatus; sheetId: string } { + if (!currentSheetId) { + throw new Error('No sheet ID'); + } if (cleanupTimeout) { return { authenticated: 'pending', sheetId: currentSheetId }; } @@ -196,22 +201,28 @@ async function verifySheet( sheetId = currentSheetId, authClient = currentAuthClient, ): Promise<{ worksheetOptions: string[] }> { + if (!sheetId || !authClient) { + throw new Error('Missing sheet ID or authentication'); + } + try { const spreadsheets = await sheets({ version: 'v4', auth: authClient }).spreadsheets.get({ spreadsheetId: sheetId, includeGridData: false, }); - return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) }; + const worksheets = spreadsheets.data.sheets?.forEach((sheet) => { + if (sheet.properties?.title) { + return sheet.properties.title; + } + }); + + if (!worksheets) { + throw new Error('No worksheets found'); + } + return worksheets; } catch (error) { // attempt to catch errors caused by importing xlsx - if ( - error.code === 400 && - Array.isArray(error.errors) && - error.errors[0].reason === 'failedPrecondition' && - error.errors[0].message === 'This operation is not supported for this document' - ) { - throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.'); - } + catchCommonImportXlsxError(error); const errorMessage = getErrorMessage(error); throw new Error(`Failed to verify sheet: ${errorMessage}`); } @@ -227,6 +238,9 @@ export async function handleInitialConnection( // we know there is an ongoing process if there is a timeout for cleanup // if there is an ongoing process, we return its data if (cleanupTimeout) { + if (!currentAuthUrl || !currentAuthCode) { + throw new Error('No ongoing connection'); + } return { verification_url: currentAuthUrl, user_code: currentAuthCode }; } @@ -255,6 +269,10 @@ export async function getWorksheetOptions(sheetId: string): ReturnType { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const spreadsheets = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.get({ spreadsheetId: sheetId, }); @@ -263,22 +281,33 @@ async function verifyWorksheet(sheetId: string, worksheet: string): Promise<{ wo throw new Error(`Request failed: ${spreadsheets.status} ${spreadsheets.statusText}`); } + if (!spreadsheets.data.sheets) { + throw new Error('No worksheets found'); + } + const selectedWorksheet = spreadsheets.data.sheets.find( - (n) => n.properties.title.toLowerCase() === worksheet.toLowerCase(), + (sheet) => sheet.properties?.title && sheet.properties.title.toLowerCase() === worksheet.toLowerCase(), ); if (!selectedWorksheet) { throw new Error('Could not find worksheet'); } + if (!selectedWorksheet.properties || !selectedWorksheet.properties.sheetId) { + throw new Error('Got invalid data from worksheet'); + } const endCell = getA1Notation( - selectedWorksheet.properties.gridProperties.rowCount, - selectedWorksheet.properties.gridProperties.columnCount, + selectedWorksheet.properties?.gridProperties?.rowCount ?? -1, + selectedWorksheet.properties?.gridProperties?.columnCount ?? -1, ); return { worksheetId: selectedWorksheet.properties.sheetId, range: `${worksheet}!A1:${endCell}` }; } export async function upload(sheetId: string, options: ImportMap) { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const { worksheetId, range } = await verifyWorksheet(sheetId, options.worksheet); const readResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ @@ -288,7 +317,7 @@ export async function upload(sheetId: string, options: ImportMap) { range, }); - if (readResponse.status !== 200) { + if (readResponse.status !== 200 || !readResponse.data.values) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } @@ -357,6 +386,10 @@ export async function download( rundown: Rundown; customFields: CustomFields; }> { + if (!currentAuthClient) { + throw new Error('Not authenticated'); + } + const { range } = await verifyWorksheet(sheetId, options.worksheet); const googleResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.values.get({ @@ -370,6 +403,10 @@ export async function download( throw new Error(`Sheet read failed: ${googleResponse.statusText}`); } + if (!googleResponse.data.values) { + throw new Error('Sheet: No data found in the worksheet'); + } + const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options); const rundownId = dataFromSheet.rundown.id; diff --git a/apps/server/src/services/sheet-service/googleApi.utils.ts b/apps/server/src/services/sheet-service/googleApi.utils.ts new file mode 100644 index 0000000000..f4d3ff846c --- /dev/null +++ b/apps/server/src/services/sheet-service/googleApi.utils.ts @@ -0,0 +1,39 @@ +// https://developers.google.com/calendar/api/guides/errors +interface GoogleApiError { + code: number; + message: string; + errors?: { + message: string; + domain: string; + reason: string; + }[]; +} + +/** + * Checks whether an error is a Google API error + */ +function isGoogleApiError(error: any): error is GoogleApiError { + return ( + typeof error === 'object' && + error !== null && + typeof error.code === 'number' && + Array.isArray(error.errors) && + typeof error.errors[0]?.reason === 'string' && + typeof error.errors[0]?.message === 'string' + ); +} + +/** + * Extract utility to handle a common error where a user imports an xlsx file instead of a Google Sheet + */ +export function catchCommonImportXlsxError(error: any) { + if ( + isGoogleApiError(error) && + error.code === 400 && + Array.isArray(error.errors) && + error.errors[0].reason === 'failedPrecondition' && + error.errors[0].message === 'This operation is not supported for this document' + ) { + throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.'); + } +} diff --git a/apps/server/src/services/sheet-service/sheetUtils.ts b/apps/server/src/services/sheet-service/sheetUtils.ts index e796318a57..f6fb59e308 100644 --- a/apps/server/src/services/sheet-service/sheetUtils.ts +++ b/apps/server/src/services/sheet-service/sheetUtils.ts @@ -2,17 +2,7 @@ import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeEntry } from 'ontime-t import { millisToString } from 'ontime-utils'; import type { sheets_v4 } from '@googleapis/sheets'; -import { isObject } from '../../utils/assert.js'; - -// we expect client secret file to contain the following keys -const requiredClientKeys = [ - 'client_id', - 'auth_uri', - 'token_uri', - 'token_uri', - 'auth_provider_x509_cert_url', - 'client_secret', -]; +import { is } from '../../utils/is.js'; export type ClientSecret = { installed: { @@ -29,19 +19,25 @@ export type ClientSecret = { * @param clientSecret * @throws */ -export function validateClientSecret(clientSecret: object): clientSecret is ClientSecret { +export function isClientSecret(clientSecret: object): clientSecret is ClientSecret { if (!('installed' in clientSecret)) { - throw new Error('Missing "installed" object'); + return false; } const { installed } = clientSecret; - isObject(installed); - - if (requiredClientKeys.every((key) => Object.keys(installed).includes(key))) { - return; + if (!is.object(installed)) { + return false; } - throw new Error('Missing keys in "installed" object'); + // we expect client secret file to contain the following keys + return is.objectWithKeys(installed, [ + 'client_id', + 'auth_uri', + 'token_uri', + 'token_uri', + 'auth_provider_x509_cert_url', + 'client_secret', + ]); } /** diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 2d5384260c..b5583708d8 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -1,3 +1,13 @@ +import { MILLIS_PER_MINUTE } from 'ontime-utils'; + +export const timerConfig = { + skipLimit: 1000, // threshold of skip for recalculating, values lower than updateRate can cause issues with rolling over midnight + updateRate: 32, // how often do we update the timer + notificationRate: 1000, // how often do we notify clients and integrations + triggerAhead: 10, // how far ahead do we trigger the end event + auxTimerDefault: 5 * MILLIS_PER_MINUTE, // default aux timer duration +}; + export const config = { appState: 'app-state.json', corrupt: 'corrupt files', diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index a095a71a99..54ab4d17c2 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -25,9 +25,9 @@ import { getRuntimeOffset, getTimerPhase, } from '../services/timerUtils.js'; -import { timerConfig } from '../config/config.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js'; +import { timerConfig } from '../setup/config.js'; export type RuntimeState = { clock: number; // realtime clock @@ -141,10 +141,12 @@ export function clearState() { function patchTimer(newState: Partial) { for (const key in newState) { if (key in runtimeState.timer) { + // @ts-expect-error -- not sure how to type this in a sane way runtimeState.timer[key] = newState[key]; } else if (key in runtimeState._timer) { // in case of a RestorePoint we will receive a pausedAt value - // wiche is needed to resume a paused timer + // which is needed to resume a paused timer + // @ts-expect-error -- not sure how to type this in a sane way runtimeState._timer[key] = newState[key]; } } @@ -423,6 +425,15 @@ export function start(state: RuntimeState = runtimeState): boolean { const { absoluteOffset, relativeOffset } = getRuntimeOffset(runtimeState); runtimeState.runtime.offset = absoluteOffset; runtimeState.runtime.relativeOffset = relativeOffset; + + // as long as there is a timer, we need an planned end + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (state.runtime.plannedEnd === null) { + throw new Error('runtimeState.start: invalid state received'); + } + } + state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset; return true; } diff --git a/apps/server/src/utils/__tests__/assert.test.ts b/apps/server/src/utils/__tests__/assert.test.ts new file mode 100644 index 0000000000..5d7db5a03d --- /dev/null +++ b/apps/server/src/utils/__tests__/assert.test.ts @@ -0,0 +1,34 @@ +import { hasKeys, isArray, isDefined, isNumber, isObject, isString } from '../assert.js'; + +describe('assert utilities', () => { + it('should assert strings', () => { + expect(() => isString('hello')).not.toThrow(); + expect(() => isString(123)).toThrow('Unexpected payload type: 123'); + }); + + it('should assert numbers', () => { + expect(() => isNumber(123)).not.toThrow(); + expect(() => isNumber('123')).toThrow('Unexpected payload type: 123'); + }); + + it('should assert defined values', () => { + expect(() => isDefined('value')).not.toThrow(); + expect(() => isDefined(undefined)).toThrow('Payload not found'); + }); + + it('should assert objects', () => { + expect(() => isObject({})).not.toThrow(); + expect(() => isObject(null)).toThrow('Unexpected payload type: null'); + expect(() => isObject([])).toThrow('Unexpected payload type: '); + }); + + it('should assert objects with specific keys', () => { + expect(() => hasKeys({ a: 1, b: 2 }, ['a', 'b'])).not.toThrow(); + expect(() => hasKeys({ a: 1 }, ['a', 'b'])).toThrow('Unexpected payload type: [object Object]'); + }); + + it('should assert arrays', () => { + expect(() => isArray([1, 2, 3])).not.toThrow(); + expect(() => isArray('not an array')).toThrow('Unexpected payload type: not an array'); + }); +}); diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 8b6d77973d..3c619ec038 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -31,15 +31,16 @@ describe('test parseDatabaseModel() with demo project (valid)', () => { const filteredDemoProject = structuredClone(demoDb); const { data } = parseDatabaseModel(filteredDemoProject); - delete filteredDemoProject.settings.version; - delete data.settings.version; - it('has 16 events', () => { expect(data.rundowns.demo.order.length).toBe(16); expect(Object.keys(data.rundowns.demo.entries).length).toBe(16); }); it('is the same as the demo project since all data is valid', () => { + // @ts-expect-error -- its ok + delete filteredDemoProject.settings.version; + // @ts-expect-error -- its ok + delete data.settings.version; expect(data).toMatchObject(filteredDemoProject); }); }); diff --git a/apps/server/src/utils/__tests__/parserUtils.test.ts b/apps/server/src/utils/__tests__/parserUtils.test.ts index d5d1430d43..d9d414181a 100644 --- a/apps/server/src/utils/__tests__/parserUtils.test.ts +++ b/apps/server/src/utils/__tests__/parserUtils.test.ts @@ -1,4 +1,4 @@ -import { isEmptyObject, mergeObject, removeUndefined } from '../parserUtils.js'; +import { isEmptyObject, removeUndefined } from '../parserUtils.js'; describe('isEmptyObject()', () => { test('finds an empty object', () => { @@ -11,92 +11,6 @@ describe('isEmptyObject()', () => { }); }); -describe('mergeObject()', () => { - test('it suppresses undefined keys', () => { - const a = { - first: 'yes', - second: 'yes', - }; - const b = { - first: undefined, - second: 'no', - }; - const merged = mergeObject(a, b); - expect(merged).toStrictEqual({ - first: 'yes', - second: 'no', - }); - }); - test('it handles falsy values', () => { - const a = { - first: 'yes', - second: 'yes' as string | null, - third: 'yes', - }; - const b = { - first: 'no', - second: null, - third: '', - }; - const merged = mergeObject(a, b); - expect(merged).toStrictEqual({ - first: 'no', - second: null, - third: '', - }); - }); - test('it only merges fields of the first object', () => { - const a = { - first: 'yes', - second: 'yes', - third: 'yes', - }; - const b = { - first: 0, - second: null, - third: '', - forth: 'not-this', - }; - // @ts-expect-error -- testing changing type - const merged = mergeObject(a, b); - expect(merged).toStrictEqual({ - first: 0, - second: null, - third: '', - }); - }); - test('merges nested objects', () => { - // Define a sample object with nested properties - const a = { - name: 'John', - address: { - city: 'New York', - postalCode: '10001', - }, - }; - - // Define a partial object with nested properties for merging - const b = { - name: 'Doe', - address: { - city: 'San Francisco', - state: 'CA', - }, - }; - - // @ts-expect-error -- testing missing property - const merged = mergeObject(a, b); - - expect(merged.name).toBe('Doe'); - expect(merged.address.city).toBe('San Francisco'); - // @ts-expect-error -- its ok, just checking - expect(merged.address.state).toBe('CA'); - expect(merged.address.postalCode).toBe('10001'); - expect(merged.address).not.toBe(a.address); - expect(merged.address).not.toBe(b.address); - }); -}); - describe('removeUndefined()', () => { test('it removes undefined keys from object', () => { const obj = { diff --git a/apps/server/src/utils/assert.ts b/apps/server/src/utils/assert.ts index 1a9b9fb2e6..19b7457efa 100644 --- a/apps/server/src/utils/assert.ts +++ b/apps/server/src/utils/assert.ts @@ -1,29 +1,31 @@ +import { is } from './is.js'; + export function isString(value: unknown): asserts value is string { - if (typeof value !== 'string') { + if (!is.string(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isDefined(value: T | undefined): asserts value is T { - if (value === undefined) { + if (!is.defined(value)) { throw new Error('Payload not found'); } } export function isNumber(value: unknown): asserts value is number { - if (typeof value !== 'number') { + if (!is.number(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isObject(value: unknown): asserts value is object { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + if (!is.object(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } export function isArray(value: unknown): asserts value is unknown[] { - if (!Array.isArray(value)) { + if (!is.array(value)) { throw new Error(`Unexpected payload type: ${String(value)}`); } } @@ -32,9 +34,7 @@ export function hasKeys( value: T, keys: K[], ): asserts value is T & Record { - for (const key of keys) { - if (!(key in value)) { - throw new Error(`Key not found: ${String(key)}`); - } + if (!is.objectWithKeys(value, keys)) { + throw new Error(`Unexpected payload type: ${String(value)}`); } } diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index d4da7c4416..1a7c17a138 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -19,7 +19,7 @@ export function ensureDirectory(directory: string): void { /** * Ensures that a filename ends with .json extension */ -export function ensureJsonExtension(filename: string | undefined): string | undefined { +export function ensureJsonExtension(filename: string): string { if (!filename) return filename; return filename.endsWith('.json') ? filename : `${filename}.json`; } diff --git a/apps/server/src/utils/is.ts b/apps/server/src/utils/is.ts new file mode 100644 index 0000000000..2c243f1c32 --- /dev/null +++ b/apps/server/src/utils/is.ts @@ -0,0 +1,10 @@ +export const is = { + string: (value: unknown): value is string => typeof value === 'string', + number: (value: unknown): value is number => typeof value === 'number', + defined: (value: T | undefined): value is T => value !== undefined, + object: (value: unknown): value is object => typeof value === 'object' && value !== null && !Array.isArray(value), + objectWithKeys: (value: T, keys: K[]): value is T & Record => { + return keys.every((key) => key in value); + }, + array: (value: unknown): value is unknown[] => Array.isArray(value), +}; diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 7a6b80c752..fa13e515ce 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -32,6 +32,7 @@ import { makeString } from './parserUtils.js'; import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; import { Merge } from 'ts-essentials'; +import { is } from './is.js'; export type ErrorEmitter = (message: string) => void; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; @@ -57,14 +58,19 @@ export function getCustomFieldData( customFieldImportKeys: Record; } { const customFields = {}; - const customFieldImportKeys = {}; + const customFieldImportKeys: Record = {}; + for (const ontimeLabel in importMap.custom) { const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel); + if (!ontimeKey) { + continue; + } const importLabel = importMap.custom[ontimeLabel].toLowerCase(); - const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : ''; + + // @ts-expect-error -- we are sure that the key exists customFields[ontimeKey] = { type: 'string', - colour, + colour: ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '', label: ontimeLabel, }; customFieldImportKeys[importLabel] = ontimeKey; @@ -92,8 +98,9 @@ export const parseExcel = ( const importMap: ImportMap = { ...defaultImportMap, ...options }; for (const [key, value] of Object.entries(importMap)) { - if (typeof value === 'string') { - importMap[key] = value.toLocaleLowerCase().trim(); + if (is.string(value)) { + // @ts-expect-error -- we are sure that the key exists + importMap[key] = value.toLowerCase().trim(); } } @@ -278,6 +285,7 @@ export const parseExcel = ( // check if it is an ontime column if (handlers[columnText]) { + // @ts-expect-error -- its ok handlers[columnText](rowIndex, j, undefined, undefined); } diff --git a/apps/server/src/utils/parserUtils.ts b/apps/server/src/utils/parserUtils.ts index 52fa2c5ad6..e6829d4771 100644 --- a/apps/server/src/utils/parserUtils.ts +++ b/apps/server/src/utils/parserUtils.ts @@ -1,5 +1,4 @@ import { unlink } from 'fs'; -import { deepmerge } from 'ontime-utils'; /** * @description Ensures variable is string, it skips object types @@ -35,33 +34,6 @@ export const isEmptyObject = (obj: object) => { throw new Error('Variable is not an object'); }; -/** - * @description Merges two objects, suppressing undefined keys - * @param {object} a - any object - * @param {object} b - a potential partial object of same time as a - */ -export function mergeObject(a: T, b: Partial): T { - const merged = { ...a }; - - for (const key in b) { - const aValue = a[key]; - const bValue = b[key]; - - // ignore keys that do not exist in original object - if (!Object.hasOwn(merged, key)) { - continue; - } - - if (typeof bValue === 'object' && bValue !== null && typeof aValue === 'object' && aValue !== null) { - // @ts-expect-error -- not sure how to type this - merged[key] = deepmerge(aValue, bValue); - } else if (bValue !== undefined) { - merged[key] = bValue; - } - } - return merged; -} - /** * @description Removes undefined * @param {object} obj diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 6daff1a8c5..f331d534d7 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "strict": false, + "strict": true, "target": "ESNext", "module": "Node16", "moduleResolution": "Node16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05818ba809..da0fb7ef96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -343,6 +343,9 @@ importers: specifier: ^0.18.5 version: 0.18.5 devDependencies: + '@types/cookie-parser': + specifier: ^1.4.8 + version: 1.4.8(@types/express@4.17.17) '@types/cors': specifier: ^2.8.17 version: 2.8.17 @@ -2031,6 +2034,11 @@ packages: '@types/connect@3.4.35': resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} + '@types/cookie-parser@1.4.8': + resolution: {integrity: sha512-l37JqFrOJ9yQfRQkljb41l0xVphc7kg5JTjjr+pLRZ0IyZ49V4BQ8vbF4Ut2C2e+WH4al3xD3ZwYwIUfnbT4NQ==} + peerDependencies: + '@types/express': '*' + '@types/cors@2.8.17': resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} @@ -6807,6 +6815,10 @@ snapshots: dependencies: '@types/node': 22.10.10 + '@types/cookie-parser@1.4.8(@types/express@4.17.17)': + dependencies: + '@types/express': 4.17.17 + '@types/cors@2.8.17': dependencies: '@types/node': 22.10.10 From 0ae7178046f251c68dd7300a04b3716db878ce53 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 25 Mar 2025 08:53:08 +0100 Subject: [PATCH 06/49] refactor: clearer relationship on rundown elements --- apps/client/src/features/rundown/Rundown.module.scss | 2 +- apps/client/src/features/rundown/RundownExport.module.scss | 2 +- apps/client/src/features/rundown/_blockMixins.scss | 6 ------ .../src/features/rundown/block-block/BlockBlock.module.scss | 2 -- .../src/features/rundown/delay-block/DelayBlock.module.scss | 3 +-- .../rundown/quick-add-block/QuickAddBlock.module.scss | 1 - .../rundown/rundown-header/RundownHeader.module.scss | 2 +- 7 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index 93fcce5c5e..8573fbb231 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -7,7 +7,6 @@ margin-top: 1rem; display: flex; flex-direction: column; - padding-right: 4px; // size of scrollbar overflow-y: scroll; height: 100%; } @@ -16,6 +15,7 @@ overflow-x: clip; display: flex; flex-direction: column; + padding-inline: 1rem; } .empty { diff --git a/apps/client/src/features/rundown/RundownExport.module.scss b/apps/client/src/features/rundown/RundownExport.module.scss index 957d482c4b..5a2f8b4dde 100644 --- a/apps/client/src/features/rundown/RundownExport.module.scss +++ b/apps/client/src/features/rundown/RundownExport.module.scss @@ -25,7 +25,7 @@ .list { @include editor.panel; height: inherit; - padding-left: 0; + padding-inline: 0; box-shadow: $box-shadow-right; flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */ diff --git a/apps/client/src/features/rundown/_blockMixins.scss b/apps/client/src/features/rundown/_blockMixins.scss index 0792493646..8f769edb35 100644 --- a/apps/client/src/features/rundown/_blockMixins.scss +++ b/apps/client/src/features/rundown/_blockMixins.scss @@ -21,18 +21,12 @@ $block-cursor-color: $orange-400; 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; 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 b5d6345a6b..92d8e6526c 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -1,9 +1,7 @@ @use '../blockMixins' as *; .block { - @include block-spacing; @include block-styling; - margin-left: 0.5rem; background-color: $block-bg2; 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..0b93a38e67 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,10 @@ @use '../blockMixins' as *; .delay { - @include block-spacing; @include block-styling; - margin-left: 0.5rem; background-color: $block-bg2; + padding-right: 0.5rem; display: grid; grid-template-columns: 2rem 1fr auto; 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..a3fa9aec6a 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 @@ -5,7 +5,6 @@ margin: 0.25rem 0; font-size: calc(1rem - 3px); - padding-inline: calc(2em + 0.5rem) 0.75rem; } .quickBtn { 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; From 58023dce0da7f1be334fe476c0b6742d87ccf2e8 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 15 Mar 2025 09:04:33 +0100 Subject: [PATCH 07/49] refactor: restructure model to contain an object of rundowns --- apps/server/src/services/sheet-service/SheetService.ts | 4 ++-- apps/server/src/utils/parser.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 44eac4d157..4e51dbca30 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -13,8 +13,8 @@ import got from 'got'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; -import { parseRundowns } from '../../utils/parserFunctions.js'; -import { getCurrentRundown, getRundownOrThrow } from '../rundown-service/rundownUtils.js'; +import { parseRundown } from '../../utils/parserFunctions.js'; +import { getRundown } from '../rundown-service/rundownUtils.js'; import { getCustomFields } from '../rundown-service/rundownCache.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js'; diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index fa13e515ce..e1c35fefaf 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -24,6 +24,8 @@ import { TimeStrategy, } from 'ontime-types'; +import { Merge } from 'ts-essentials'; + import { parseAutomationSettings } from '../api-data/automation/automation.parser.js'; import { logger } from '../classes/Logger.js'; import { event as eventDef } from '../models/eventsDefinition.js'; @@ -31,7 +33,6 @@ import { event as eventDef } from '../models/eventsDefinition.js'; import { makeString } from './parserUtils.js'; import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; -import { Merge } from 'ts-essentials'; import { is } from './is.js'; export type ErrorEmitter = (message: string) => void; From de0ac9205266dccbbb5812b4b13b49df6829379b Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Thu, 27 Mar 2025 17:19:22 +0100 Subject: [PATCH 08/49] chore: rename files --- .../{rundownCacheUtils.test.ts => rundownCache.utils.test.ts} | 2 +- .../{rundownCacheUtils.ts => rundownCache.utils.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename apps/server/src/services/rundown-service/__tests__/{rundownCacheUtils.test.ts => rundownCache.utils.test.ts} (99%) rename apps/server/src/services/rundown-service/{rundownCacheUtils.ts => rundownCache.utils.ts} (100%) diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts similarity index 99% rename from apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts rename to apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts index a8f4b832bf..b58a78a5cc 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts @@ -14,7 +14,7 @@ import { handleLink, hasChanges, isDataStale, -} from '../rundownCacheUtils.js'; +} from '../rundownCache.utils.js'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts similarity index 100% rename from apps/server/src/services/rundown-service/rundownCacheUtils.ts rename to apps/server/src/services/rundown-service/rundownCache.utils.ts From 7a78ed47209e161f9e2cdcabb301d7612666513e Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 28 Mar 2025 21:35:23 +0100 Subject: [PATCH 09/49] refactor: swap maintains schedule --- apps/server/src/services/rundown-service/rundownCache.ts | 2 ++ packages/utils/src/rundown-utils/rundownUtils.test.ts | 8 ++++++-- packages/utils/src/rundown-utils/rundownUtils.ts | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 5a2ce87c60..99257e3fa0 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -525,6 +525,8 @@ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { rundown.entries[fromId] = newFrom; rundown.entries[toId] = newTo; + newFrom.revision++; + newTo.revision++; setIsStale(); return { newRundown: rundown, didMutate: true }; diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index b25522130d..efdc80e0d4 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -203,6 +203,7 @@ describe('swapEventData', () => { duration: 1, delay: 1, revision: 3, + currentBlock: null, } as OntimeEvent; const eventB = { id: '2', @@ -212,6 +213,7 @@ describe('swapEventData', () => { duration: 2, delay: 2, revision: 7, + currentBlock: 'testing', } as OntimeEvent; const [newA, newB] = swapEventData(eventA, eventB); @@ -223,7 +225,8 @@ describe('swapEventData', () => { timeEnd: 1, duration: 1, delay: 1, - revision: 4, + revision: 3, + currentBlock: null, }); expect(newB).toMatchObject({ id: '2', @@ -232,7 +235,8 @@ describe('swapEventData', () => { timeEnd: 2, duration: 2, delay: 2, - revision: 8, + revision: 7, + currentBlock: 'testing', }); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index df7f77f97e..a3937cb527 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -278,10 +278,13 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: timeEnd: eventA.timeEnd, duration: eventA.duration, linkStart: eventA.linkStart, + currentBlock: eventA.currentBlock, // keep schedule metadata delay: eventA.delay, gap: eventA.gap, dayOffset: eventA.dayOffset, + // keep revision number + revision: eventA.revision, }; const newB = { @@ -293,10 +296,13 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: timeEnd: eventB.timeEnd, duration: eventB.duration, linkStart: eventB.linkStart, + currentBlock: eventB.currentBlock, // keep schedule metadata delay: eventB.delay, gap: eventB.gap, dayOffset: eventB.dayOffset, + // keep revision number + revision: eventB.revision, }; return [newA, newB]; From 8f5a4f4a0a40079ef5751bbb16417da06febb790 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 28 Mar 2025 19:16:38 +0100 Subject: [PATCH 10/49] refactor: gather group metadata --- apps/client/package.json | 2 +- .../client/src/common/hooks/useEventAction.ts | 11 +- .../utils/__tests__/eventsManager.test.ts | 4 +- .../src/features/rundown/BlockEmpty.tsx | 20 ++ .../src/features/rundown/Empty.module.scss | 4 + .../src/features/rundown/Rundown.module.scss | 9 - apps/client/src/features/rundown/Rundown.tsx | 156 ++++++----- .../src/features/rundown/RundownEmpty.tsx | 8 +- .../src/features/rundown/RundownEntry.tsx | 10 - .../block-block/BlockBlock.module.scss | 64 ++++- .../rundown/block-block/BlockBlock.tsx | 62 ++++- .../rundown/event-block/EventBlock.tsx | 6 +- .../rundown/event-block/EventBlockInner.tsx | 4 +- .../event-editor/RundownEventEditor.tsx | 2 +- .../composite/EventEditorTimes.tsx | 4 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 25 +- .../src/features/rundown/rundown.utils.ts | 91 ++++++ .../rundown/time-input-flow/TimeInputFlow.tsx | 14 +- .../cuesheet-table-elements/cuesheetCols.tsx | 2 +- .../src/views/timeline/timeline.utils.ts | 4 +- .../api-integration/integration.controller.ts | 2 +- apps/server/src/models/demoProject.ts | 57 ++-- apps/server/src/models/eventsDefinition.ts | 12 +- .../src/services/__tests__/timerUtils.test.ts | 10 +- .../__mocks__/rundown.mocks.ts | 1 + .../__tests__/delayUtils.test.ts | 48 ++-- .../__tests__/rundownCache.test.ts | 212 ++++++++++---- .../__tests__/rundownCache.utils.test.ts | 73 +---- .../services/rundown-service/delayUtils.ts | 2 +- .../services/rundown-service/rundown.types.ts | 20 ++ .../services/rundown-service/rundownCache.ts | 260 +++++++----------- .../rundown-service/rundownCache.utils.ts | 203 +++++++++++--- .../services/sheet-service/SheetService.ts | 8 +- .../__tests__/sheetUtils.test.ts | 12 +- .../server/src/utils/__tests__/parser.test.ts | 22 +- .../utils/__tests__/parserFunctions.test.ts | 158 +++-------- apps/server/src/utils/parser.ts | 5 +- apps/server/src/utils/parserFunctions.ts | 39 ++- apps/server/test-db/db.json | 28 +- e2e/tests/000-upload-showfile.spec.ts | 6 +- .../features/209-rundown-shortcuts.spec.ts | 37 ++- e2e/tests/fixtures/e2e-test-db.json | 28 +- .../src/definitions/core/OntimeEvent.type.ts | 4 +- packages/utils/index.ts | 4 +- ...omPrevious.test.ts => getTimeFrom.test.ts} | 20 +- ...{getTimeFromPrevious.ts => getTimeFrom.ts} | 4 +- .../src/validate-events/validateEvent.ts | 14 - pnpm-lock.yaml | 12 +- 48 files changed, 1048 insertions(+), 755 deletions(-) create mode 100644 apps/client/src/features/rundown/BlockEmpty.tsx create mode 100644 apps/client/src/features/rundown/Empty.module.scss create mode 100644 apps/client/src/features/rundown/rundown.utils.ts create mode 100644 apps/server/src/services/rundown-service/rundown.types.ts rename packages/utils/src/date-utils/{getTimeFromPrevious.test.ts => getTimeFrom.test.ts} (76%) rename packages/utils/src/date-utils/{getTimeFromPrevious.ts => getTimeFrom.ts} (91%) diff --git a/apps/client/package.json b/apps/client/package.json index 998b03fe64..8d202a718d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@fontsource/open-sans": "^5.0.28", - "@mantine/hooks": "^7.13.3", + "@mantine/hooks": "^7.17.2", "@sentry/react": "^8.43.0", "@table-nav/react": "^0.0.7", "@tanstack/react-query": "^5.62.7", diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index e2e8b10649..41d9fc1b64 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -97,9 +97,7 @@ export const useEventAction = () => { linkPrevious: options?.linkPrevious ?? linkPrevious, }; - if (applicationOptions.linkPrevious && applicationOptions?.lastEventId) { - newEvent.linkStart = applicationOptions.lastEventId; - } else if (applicationOptions?.lastEventId) { + if (applicationOptions?.lastEventId) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value const rundownData = queryClient.getQueryData(RUNDOWN)!; const previousEvent = rundownData.entries[applicationOptions.lastEventId]; @@ -109,9 +107,8 @@ export const useEventAction = () => { } // Override event with options from editor settings - if (applicationOptions.defaultPublic) { - newEvent.isPublic = true; - } + newEvent.linkStart = applicationOptions.linkPrevious; + newEvent.isPublic = applicationOptions.defaultPublic; if (newEvent.duration === undefined && newEvent.timeEnd === undefined) { newEvent.duration = parseUserTime(defaultDuration); @@ -263,7 +260,7 @@ export const useEventAction = () => { newEvent.duration = value === '' ? undefined : calculateNewValue(); } else if (field === 'timeStart') { // an empty values means we should link to the previous - newEvent.linkStart = value === '' ? 'true' : null; + newEvent.linkStart = value === ''; newEvent.timeStart = value === '' ? undefined : calculateNewValue(); } } else { diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index f54b5d7687..7cd2632299 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -15,7 +15,8 @@ describe('cloneEvent()', () => { timeEnd: 10, timerType: TimerType.CountDown, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + currentBlock: 'test', + linkStart: false, countToEnd: false, endAction: EndAction.None, isPublic: false, @@ -45,6 +46,7 @@ describe('cloneEvent()', () => { timeEnd: original.timeEnd, timerType: original.timerType, timeStrategy: original.timeStrategy, + currentBlock: 'test', countToEnd: original.countToEnd, linkStart: original.linkStart, endAction: original.endAction, diff --git a/apps/client/src/features/rundown/BlockEmpty.tsx b/apps/client/src/features/rundown/BlockEmpty.tsx new file mode 100644 index 0000000000..f828968e88 --- /dev/null +++ b/apps/client/src/features/rundown/BlockEmpty.tsx @@ -0,0 +1,20 @@ +import { IoAdd } from 'react-icons/io5'; +import { Button } from '@chakra-ui/react'; + +import style from './Empty.module.scss'; + +interface BlockEmptyProps { + handleAddNew: () => void; +} + +export default function BlockEmpty(props: BlockEmptyProps) { + const { handleAddNew } = props; + + return ( +
+ +
+ ); +} diff --git a/apps/client/src/features/rundown/Empty.module.scss b/apps/client/src/features/rundown/Empty.module.scss new file mode 100644 index 0000000000..7ed3e524c9 --- /dev/null +++ b/apps/client/src/features/rundown/Empty.module.scss @@ -0,0 +1,4 @@ +.empty { + padding-block: 1.5rem; + text-align: center; +} diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index 8573fbb231..a5da6dd8ec 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -23,15 +23,6 @@ align-self: center; } -.alignCenter { - text-align: center; - flex-direction: column; - - .spaceTop { - margin-top: 1.5rem; - } -} - .spacer { min-height: 50vh; } diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 392f9c4aee..7d65c04cf6 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -5,23 +5,19 @@ import { useHotkeys } from '@mantine/hooks'; import { type EntryId, type MaybeString, - type PlayableEvent, type Rundown, isOntimeBlock, isOntimeEvent, - isPlayableEvent, Playback, SupportedEvent, } from 'ontime-types'; import { - checkIsNextDay, getFirstNormal, getLastNormal, getNextBlockNormal, getNextNormal, getPreviousBlockNormal, getPreviousNormal, - isNewLatest, reorderArray, } from 'ontime-utils'; @@ -32,7 +28,10 @@ import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { cloneEvent } from '../../common/utils/eventsManager'; +import BlockBlock from './block-block/BlockBlock'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; +import BlockEmpty from './BlockEmpty'; +import { makeRundownMetadata } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -264,20 +263,11 @@ export default function Rundown({ data }: RundownProps) { return insertAtId(SupportedEvent.Event, cursor)} />; } - // last event is used to calculate relative timings - let lastEvent: PlayableEvent | null = null; // used by indicators - let thisEvent: PlayableEvent | null = null; - // previous entry is used to infer position in the rundown for new events - let previousEntryId: MaybeString = null; - let thisId: MaybeString = null; - - let eventIndex = 0; - // all events before the current selected are in the past - let isPast = Boolean(featureData?.selectedEventId); - let isNextDay = false; - let totalGap = 0; + // 1. gather presentation options const isEditMode = appMode === AppMode.Edit; - let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event + + // 2. initialise rundown metadata + const process = makeRundownMetadata(featureData?.selectedEventId); return (
@@ -292,64 +282,96 @@ export default function Rundown({ data }: RundownProps) { if (!entry) { return null; } - if (index === 0) { - eventIndex = 0; - } - isNextDay = false; - previousEntryId = thisId; - thisId = entryId; - if (isOntimeEvent(entry)) { - // event indexes are 1 based in frontend - eventIndex++; - lastEvent = thisEvent; - - if (isPlayableEvent(entry)) { - isNextDay = checkIsNextDay(entry, lastEvent); - if (!isPast) { - totalGap += entry.gap; - // We also include countToEnd in this test as the behavior of a linked event coming after a countToEnd is simelar to an unlinked event - isLinkedToLoaded = isLinkedToLoaded && entry.linkStart !== null && !lastEvent?.countToEnd; - } - if (isNewLatest(entry, lastEvent)) { - // populate previous entry - thisEvent = entry; - } - } - } + + const rundownMeta = process(entry); const isFirst = index === 0; const isLast = index === order.length - 1; - const isLoaded = featureData?.selectedEventId === entry.id; const isNext = featureData?.nextEventId === entry.id; const hasCursor = entry.id === cursor; - if (isLoaded) { - isPast = false; - } return ( - {isEditMode && (hasCursor || isFirst) && } -
- {isOntimeEvent(entry) &&
{eventIndex}
} -
- + {isEditMode && (hasCursor || isFirst) && ( + + )} + {isOntimeBlock(entry) ? ( + + {entry.events.length === 0 && ( + insertAtId(SupportedEvent.Event, cursor)} /> + )} + {entry.events.map((eventId, nestedIndex) => { + const nestedEntry = entries[eventId]; + const nestedRundownMeta = process(nestedEntry); + const isFirstInGroup = nestedIndex === 0; + const isLastInGroup = nestedIndex === entry.events.length - 1; + const hasNestedCursor = nestedEntry.id === cursor; + + if (!isOntimeEvent(nestedEntry)) { + return null; + } + return ( + + {isEditMode && (hasNestedCursor || isFirstInGroup) && ( + + )} + +
+
{nestedRundownMeta.eventIndex}
+
+ +
+
+ {isEditMode && (hasNestedCursor || isLastInGroup) && ( + + )} +
+ ); + })} +
+ ) : ( +
+ {isOntimeEvent(entry) &&
{rundownMeta.eventIndex}
} +
+ +
-
- {isEditMode && (hasCursor || isLast) && } + )} + {isEditMode && (hasCursor || isLast) && } ); })} diff --git a/apps/client/src/features/rundown/RundownEmpty.tsx b/apps/client/src/features/rundown/RundownEmpty.tsx index 2de016e989..6ce8cb5b11 100644 --- a/apps/client/src/features/rundown/RundownEmpty.tsx +++ b/apps/client/src/features/rundown/RundownEmpty.tsx @@ -3,7 +3,7 @@ import { Button } from '@chakra-ui/react'; import Empty from '../../common/components/state/Empty'; -import style from './Rundown.module.scss'; +import style from './Empty.module.scss'; interface RundownEmptyProps { handleAddNew: () => void; @@ -13,9 +13,9 @@ export default function RundownEmpty(props: RundownEmptyProps) { const { handleAddNew } = props; return ( -
- -
diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 70153796a2..2dcf49001f 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react'; import { - isOntimeBlock, isOntimeDelay, isOntimeEvent, MaybeString, @@ -15,7 +14,6 @@ import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import { useEmitLog } from '../../common/stores/logger'; import { cloneEvent } from '../../common/utils/eventsManager'; -import BlockBlock from './block-block/BlockBlock'; import DelayBlock from './delay-block/DelayBlock'; import EventBlock from './event-block/EventBlock'; import { useEventSelection } from './useEventSelection'; @@ -193,14 +191,6 @@ export default function RundownEntry(props: RundownEntryProps) { actionHandler={actionHandler} /> ); - } else if (isOntimeBlock(data)) { - return ( - - {data.events.map((eventId) => { - return
{eventId}
; - })} -
- ); } else if (isOntimeDelay(data)) { return ; } 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 92d8e6526c..52830d6ead 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -2,19 +2,75 @@ .block { @include block-styling; - - background-color: $block-bg2; + overflow: hidden; min-width: 34rem; + display: grid; grid-template-columns: 2rem 1fr auto; + grid-template-areas: + 'binder header' + 'content content' + 'footer footer'; align-items: center; - height: $secondary-block-height; - gap: 0.5rem; &.hasCursor { outline: 1px solid $block-cursor-color; } + + .binder { + grid-area: binder; + height: 100%; + background-color: $gray-1050; // to override inline + 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 { + font-size: calc(1rem - 3px); + width: 4.5em; + + :first-child { + color: $label-gray; + } + } + + .group { + background-color: color-mix(in srgb, var(--user-bg, $gray-1050) 10%, transparent 90%); + grid-area: content; + padding-right: 2px; + box-sizing: content-box; + } + + .footer { + grid-area: footer; + background-color: var(--user-bg, $gray-1050) ; + height: 0.25rem; + } } .drag { diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index 5f3d0abad8..fb42bc84a5 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,10 +1,12 @@ import { PropsWithChildren, useRef } from 'react'; -import { IoReorderTwo } from 'react-icons/io5'; +import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useSessionStorage } from '@mantine/hooks'; import { OntimeBlock } from 'ontime-types'; -import { cx } from '../../../common/utils/styleUtils'; +import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; +import { formatDuration, formatTime } from '../../../common/utils/time'; import EditableBlockTitle from '../common/EditableBlockTitle'; import style from './BlockBlock.module.scss'; @@ -16,7 +18,7 @@ interface BlockBlockProps { export default function BlockBlock(props: PropsWithChildren) { const { data, hasCursor, children } = props; - + const [collapsed, setCollapsed] = useSessionStorage({ key: `block-${data.id}`, defaultValue: false }); const handleRef = useRef(null); const { @@ -35,16 +37,54 @@ export default function BlockBlock(props: PropsWithChildren) { transition, }; - const blockClasses = cx([style.block, hasCursor ? style.hasCursor : null]); + const binderColours = data.colour && getAccessibleColour(data.colour); return ( -
- - - - - -
{children}
+
+
+ + + +
+
+
+ + +
+
+
+
Start
+
{formatTime(data.startTime)}
+
+
+
End
+
{formatTime(data.endTime)}
+
+
+
Duration
+
{formatDuration(data.duration)}
+
+
+
Events
+
{data.numEvents}
+
+
+
+ {!collapsed && ( +
+ {children} +
+ )} +
); } diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 71fab92229..2ddd307c22 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -12,7 +12,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, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; @@ -32,7 +32,7 @@ interface EventBlockProps { timeEnd: number; duration: number; timeStrategy: TimeStrategy; - linkStart: MaybeString; + linkStart: boolean; countToEnd: boolean; eventIndex: number; isPublic: boolean; @@ -151,7 +151,7 @@ export default function EventBlock(props: EventBlockProps) { onClick: () => actionHandler('update', { field: 'linkStart', - value: linkStart ? null : 'true', + value: linkStart, }), }, { diff --git a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx index ce821f33be..2b180f2e9e 100644 --- a/apps/client/src/features/rundown/event-block/EventBlockInner.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlockInner.tsx @@ -11,7 +11,7 @@ import { 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'; @@ -30,7 +30,7 @@ interface EventBlockInnerProps { timeEnd: number; duration: number; timeStrategy: TimeStrategy; - linkStart: MaybeString; + linkStart: boolean; countToEnd: boolean; eventIndex: number; isPublic: boolean; diff --git a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx index 2def764692..54ccb71a35 100644 --- a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx @@ -22,7 +22,7 @@ export default function RundownEventEditor() { return; } - const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId)); + const selectedEventId = Array.from(selectedEvents).at(0); if (!selectedEventId) { setEvent(null); 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 cd9593cc68..36c18b6980 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -1,7 +1,7 @@ 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'; @@ -18,7 +18,7 @@ interface EventEditorTimesProps { timeEnd: number; duration: number; timeStrategy: TimeStrategy; - linkStart: MaybeString; + linkStart: boolean; countToEnd: boolean; delay: number; isPublic: boolean; 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 66eb7a5969..42a675f5b5 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -10,12 +10,13 @@ import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { previousEventId: MaybeString; + showBlocks?: boolean; } export default memo(QuickAddBlock); function QuickAddBlock(props: QuickAddBlockProps) { - const { previousEventId } = props; + const { previousEventId, showBlocks } = props; const { addEvent } = useEventAction(); const { emitError } = useEmitLog(); @@ -86,16 +87,18 @@ function QuickAddBlock(props: QuickAddBlockProps) { > Delay - + {showBlocks && ( + + )}
); } 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..2ad16e3bd9 --- /dev/null +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -0,0 +1,91 @@ +import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, PlayableEvent } 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; + isNext: boolean; + isNextDay: boolean; + totalGap: number; + isLinkedToLoaded: boolean; // check if the event can link all the way back to the currently playing event + isLoaded: boolean; +}; + +/** + * 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 + isNext: false, + isNextDay: false, + totalGap: 0, + isLinkedToLoaded: true, + isLoaded: false, + }; + + function process(entry: OntimeEntry): Readonly { + const processedRundownMetadata = processEntry(rundownMeta, selectedEventId, entry); + rundownMeta = processedRundownMetadata; + return rundownMeta; + } + + return process; +} + +/** + * Receives a rundown entry and processes its place in the rundown + * + */ +function processEntry( + rundownMetadata: RundownMetadata, + selectedEventId: MaybeString, + entry: Readonly, +): Readonly { + const processedData = { ...rundownMetadata }; + processedData.isNextDay = false; + processedData.isLoaded = false; + processedData.previousEntryId = processedData.thisId; + processedData.thisId = entry.id; + + if (entry.id === selectedEventId) { + processedData.isLoaded = true; + processedData.isPast = false; + } + + if (isOntimeEvent(entry)) { + // event indexes are 1 based in UI + processedData.eventIndex += 1; + processedData.previousEvent = processedData.latestEvent; + + if (isPlayableEvent(entry)) { + processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); + + if (!processedData.isPast) { + processedData.totalGap += entry.gap; + /** + * isLinkToLoaded is a chain value that we maintain until we find an unlinked event + * or we find a countToEnd event + */ + processedData.isLinkedToLoaded = + processedData.isLinkedToLoaded && entry.linkStart && !processedData.previousEvent?.countToEnd; + } + + if (isNewLatest(entry, processedData.previousEvent)) { + // this event is the forward most event in rundown, for next iteration + processedData.latestEvent = entry; + } + } + } + + return processedData; +} 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..28680965bd 100644 --- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx +++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx @@ -1,7 +1,7 @@ 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'; @@ -19,7 +19,7 @@ interface EventBlockTimerProps { timeEnd: number; duration: number; timeStrategy: TimeStrategy; - linkStart: MaybeString; + linkStart: boolean; delay: number; showLabels?: boolean; } @@ -38,7 +38,7 @@ function TimeInputFlow(props: EventBlockTimerProps) { }; const handleLink = (doLink: boolean) => { - updateEvent({ id: eventId, linkStart: doLink ? 'true' : null }); + updateEvent({ 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/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index 9fb86773d2..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 @@ -22,7 +22,7 @@ function MakeStart({ getValue, row, table }: CellContext) const update = (newValue: string) => 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; diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index 8c10681236..847d3a0c5d 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -6,7 +6,7 @@ import { getEventWithId, getFirstEvent, getNextEvent, - getTimeFromPrevious, + getTimeFrom, isNewLatest, MILLIS_PER_HOUR, } from 'ontime-utils'; @@ -134,7 +134,7 @@ export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeS firstStart = currentEntry.timeStart; } - const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry); + const timeFromPrevious: number = getTimeFrom(currentEntry, lastEntry); if (timeFromPrevious === 0) { totalDuration += currentEntry.duration; diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index cbab39b4d3..5857848727 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -14,7 +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 { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js'; import { coerceEnum } from '../utils/coerceType.js'; const throttledUpdateEvent = throttle(updateEvent, 20); diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 94aa96c7be..de0527f1b0 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -2,15 +2,11 @@ import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } fro export const demoDb: DatabaseModel = { rundowns: { - demo: { - id: 'demo', + default: { + id: 'default', title: 'Eurovision Demo', order: [ - '32d31', - '21cd2', - '0b371', - '3cd28', - 'e457f', + 'block', '01e85', '1c420', 'b7737', @@ -24,6 +20,25 @@ export const demoDb: DatabaseModel = { 'd3eb1', ], entries: { + block: { + type: SupportedEvent.Block, + events: ['32d31', '21cd2', '0b371', '3cd28', 'e457f'], + id: 'block', + title: 'Test Block', + note: '', + skip: false, + colour: 'hotpink', + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: 0, + custom: { + song: 'Sekret', + artist: 'Ronela Hajati', + }, + }, '32d31': { type: SupportedEvent.Event, id: '32d31', @@ -33,7 +48,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 36000000, timeEnd: 37200000, @@ -62,7 +77,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 37500000, timeEnd: 38700000, @@ -91,7 +106,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 39000000, timeEnd: 40200000, @@ -120,7 +135,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 40500000, timeEnd: 41700000, @@ -149,7 +164,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 42000000, timeEnd: 43200000, @@ -196,7 +211,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 47100000, timeEnd: 48300000, @@ -225,7 +240,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 48600000, timeEnd: 49800000, @@ -254,7 +269,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 50100000, timeEnd: 51300000, @@ -283,7 +298,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 51600000, timeEnd: 52800000, @@ -312,7 +327,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 53100000, timeEnd: 54300000, @@ -359,7 +374,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 56100000, timeEnd: 57300000, @@ -388,7 +403,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 57600000, timeEnd: 58800000, @@ -417,7 +432,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 59100000, timeEnd: 60300000, @@ -446,7 +461,7 @@ export const demoDb: DatabaseModel = { endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, - linkStart: null, + linkStart: false, timeStrategy: TimeStrategy.LockEnd, timeStart: 60600000, timeEnd: 61800000, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 725aedac28..9775924e00 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -15,7 +15,7 @@ export const event: Omit = { endAction: EndAction.None, timerType: TimerType.CountDown, timeStrategy: TimeStrategy.LockDuration, - linkStart: null, + linkStart: false, countToEnd: false, timeStart: 0, timeEnd: 0, @@ -23,14 +23,15 @@ export const event: Omit = { isPublic: false, skip: false, colour: '', + timeWarning: 120000, + timeDanger: 60000, + custom: {}, + // !==== RUNTIME METADATA ====! // currentBlock: null, revision: 0, // calculated at runtime delay: 0, // calculated at runtime dayOffset: 0, // calculated at runtime gap: 0, // calculated at runtime - timeWarning: 120000, - timeDanger: 60000, - custom: {}, }; export const delay: Omit = { @@ -45,11 +46,12 @@ export const block: Omit = { events: [], skip: false, colour: '', + custom: {}, + // !==== RUNTIME METADATA ====! // revision: 0, // calculated at runtime startTime: null, // calculated at runtime endTime: null, // calculated at runtime duration: 0, // calculated at runtime isFirstLinked: false, // calculated at runtime numEvents: 0, // calculated at runtime - custom: {}, }; diff --git a/apps/server/src/services/__tests__/timerUtils.test.ts b/apps/server/src/services/__tests__/timerUtils.test.ts index bb6d056281..ec74c18982 100644 --- a/apps/server/src/services/__tests__/timerUtils.test.ts +++ b/apps/server/src/services/__tests__/timerUtils.test.ts @@ -825,7 +825,7 @@ describe('getRuntimeOffset()', () => { timeEnd: 81000000, duration: 3600000, timeStrategy: 'lock-duration', - linkStart: null, + linkStart: false, }, runtime: { selectedEventIndex: 0, @@ -863,7 +863,7 @@ describe('getRuntimeOffset()', () => { timeEnd: 84600000, duration: 3600000, timeStrategy: 'lock-duration', - linkStart: null, + linkStart: false, endAction: 'none', timerType: 'count-down', delay: 0, @@ -906,7 +906,7 @@ describe('getRuntimeOffset()', () => { timeEnd: 81000000, // 22:30:00 duration: 3600000, // 01:00:00 timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: true, @@ -959,7 +959,7 @@ describe('getRuntimeOffset()', () => { timeEnd: 81000000, // 22:30:00 duration: 3600000, // 01:00:00 timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: true, @@ -1010,7 +1010,7 @@ describe('getRuntimeOffset()', () => { timeEnd: 81000000, // 22:30:00 duration: 3600000, // 01:00:00 timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: true, diff --git a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts index 7bc16bf756..570c8c9584 100644 --- a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts +++ b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts @@ -9,6 +9,7 @@ const baseEvent = { const baseBlock = { type: SupportedEvent.Block, + events: [], }; /** diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index 5f296e0db8..e92c74ceee 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -12,10 +12,10 @@ describe('apply()', () => { entries: { delay: makeOntimeDelay({ id: 'delay', duration: 10 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), '3': makeOntimeBlock({ id: '3' }), - '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), }, }); @@ -24,10 +24,10 @@ describe('apply()', () => { expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, - '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, + '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, '3': { id: '3' }, - '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, - '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, + '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false }, + '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true }, }); }); @@ -38,10 +38,10 @@ describe('apply()', () => { entries: { delay: makeOntimeDelay({ id: 'delay', duration: -10 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }), '3': makeOntimeBlock({ id: '3' }), - '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }), }, }); @@ -50,10 +50,10 @@ describe('apply()', () => { expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, - '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, + '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false }, '3': { id: '3' }, - '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, - '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, + '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false }, + '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true }, }); }); @@ -63,7 +63,7 @@ describe('apply()', () => { entries: { delay: makeOntimeDelay({ id: 'delay', duration: -50 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }), }, }); @@ -84,7 +84,7 @@ describe('apply()', () => { timeStart: 50, timeEnd: 100, duration: 50, - linkStart: null, + linkStart: false, revision: 2, }, }); @@ -96,7 +96,7 @@ describe('apply()', () => { entries: { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), delay: makeOntimeDelay({ id: 'delay', duration: 50 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), }, }); @@ -115,7 +115,7 @@ describe('apply()', () => { timeStart: 150, timeEnd: 200, duration: 50, - linkStart: null, + linkStart: false, revision: 2, }, }); @@ -127,7 +127,7 @@ describe('apply()', () => { entries: { delay: makeOntimeDelay({ id: 'delay', duration: 50 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), }, }); @@ -146,7 +146,7 @@ describe('apply()', () => { timeStart: 150, timeEnd: 200, duration: 50, - linkStart: '1', + linkStart: true, revision: 2, }, }); @@ -158,7 +158,7 @@ describe('apply()', () => { entries: { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), delay: makeOntimeDelay({ id: 'delay', duration: -50 }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), }, }); @@ -171,7 +171,7 @@ describe('apply()', () => { timeStart: 50, timeEnd: 100, duration: 50, - linkStart: null, + linkStart: false, revision: 2, }, }); @@ -190,7 +190,7 @@ describe('apply()', () => { // gap 50 '4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), // linked - '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), + '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }), }, }); @@ -205,7 +205,7 @@ describe('apply()', () => { // gap (delay is 0) '4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, // linked - '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, + '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true }, }); }); @@ -278,7 +278,7 @@ describe('apply()', () => { '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), delay: makeOntimeDelay({ id: 'delay', duration: 50 }), block: makeOntimeBlock({ id: 'block' }), - '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }), }, }); @@ -299,7 +299,7 @@ describe('apply()', () => { timeStart: 150, timeEnd: 200, duration: 50, - linkStart: null, + linkStart: false, revision: 2, }, }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 399c36baf3..4ef18fc416 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -1,6 +1,8 @@ import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; +import { demoDb } from '../../../models/demoProject.js'; + import { add, batchEdit, @@ -15,6 +17,7 @@ import { customFieldChangelog, } from '../rundownCache.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; +import { ProcessedRundownMetadata } from '../rundownCache.utils.js'; beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -30,6 +33,23 @@ beforeAll(() => { }); describe('generate()', () => { + test('benchmark function execution time', () => { + const rundown = demoDb.rundowns.default; + const t1 = performance.now(); + let result: ProcessedRundownMetadata | null = null; + for (let i = 0; i < 100; i++) { + result = generate(rundown); + } + const t2 = performance.now(); + console.warn( + 'Rundown generation took', + t2 - t1, + 'milliseconds for 100x', + Object.keys(result?.entries ?? {}).length, + 'events', + ); + }); + it('creates normalised versions of a given rundown', () => { const rundown = makeRundown({ order: ['1', '2', '3'], @@ -43,9 +63,9 @@ describe('generate()', () => { const initResult = generate(rundown); expect(initResult.order.length).toBe(3); expect(initResult.order).toStrictEqual(['1', '2', '3']); - expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event); - expect(initResult.rundown['2'].type).toBe(SupportedEvent.Block); - expect(initResult.rundown['3'].type).toBe(SupportedEvent.Delay); + expect(initResult.entries['1'].type).toBe(SupportedEvent.Event); + expect(initResult.entries['2'].type).toBe(SupportedEvent.Block); + expect(initResult.entries['3'].type).toBe(SupportedEvent.Delay); }); it('calculates delays versions of a given rundown', () => { @@ -59,7 +79,7 @@ describe('generate()', () => { const initResult = generate(rundown); expect(initResult.order.length).toBe(2); - expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100); expect(initResult.totalDelay).toBe(100); }); @@ -79,10 +99,10 @@ describe('generate()', () => { const initResult = generate(rundown); expect(initResult.order.length).toBe(7); - expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); - expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200); - expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(100); - expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(0); + expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200); + expect((initResult.entries['3'] as OntimeEvent).delay).toBe(100); + expect((initResult.entries['4'] as OntimeEvent).delay).toBe(0); expect(initResult.totalDelay).toBe(0); expect(initResult.totalDuration).toBe(700 - 100); }); @@ -167,10 +187,10 @@ describe('generate()', () => { const initResult = generate(rundown); expect(initResult.order.length).toBe(7); - expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); - expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200); - expect((initResult.rundown['3'] as OntimeEvent).delay).toBe(-200); - expect((initResult.rundown['4'] as OntimeEvent).delay).toBe(-200); + expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); + expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200); + expect((initResult.entries['3'] as OntimeEvent).delay).toBe(-200); + expect((initResult.entries['4'] as OntimeEvent).delay).toBe(-200); expect(initResult.totalDelay).toBe(-200); expect(initResult.totalDuration).toBe(700 - 100); }); @@ -191,7 +211,7 @@ describe('generate()', () => { timeStart: 11, duration: 1, timeEnd: 12, - linkStart: '1', + linkStart: true, timeStrategy: TimeStrategy.LockEnd, }), block: makeOntimeBlock({ id: 'block' }), @@ -201,7 +221,7 @@ describe('generate()', () => { timeStart: 21, duration: 1, timeEnd: 22, - linkStart: '2', + linkStart: true, timeStrategy: TimeStrategy.LockEnd, }), }, @@ -209,16 +229,13 @@ describe('generate()', () => { const initResult = generate(rundown); expect(initResult.order.length).toBe(5); - expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2); - expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12); - expect((initResult.rundown['2'] as OntimeEvent).duration).toBe(10); + expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2); + expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12); + expect((initResult.entries['2'] as OntimeEvent).duration).toBe(10); - expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(12); - expect((initResult.rundown['3'] as OntimeEvent).timeEnd).toBe(22); - expect((initResult.rundown['3'] as OntimeEvent).duration).toBe(10); - - expect(initResult.links['1']).toBe('2'); - expect(initResult.links['2']).toBe('3'); + expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(12); + expect((initResult.entries['3'] as OntimeEvent).timeEnd).toBe(22); + expect((initResult.entries['3'] as OntimeEvent).duration).toBe(10); }); it('links times across events, reordered', () => { @@ -226,16 +243,14 @@ describe('generate()', () => { order: ['1', '3', '2'], entries: { '1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }), - '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' }), - '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' }), + '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: true }), + '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: true }), }, }); const initResult = generate(rundown); expect(initResult.order.length).toBe(3); - expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2); - expect(initResult.links['1']).toBe('3'); - expect(initResult.links['3']).toBe('2'); + expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2); }); it('calculates total duration', () => { @@ -333,7 +348,7 @@ describe('generate()', () => { timeEnd: 600000, duration: 600000, timeStrategy: TimeStrategy.LockDuration, - linkStart: null, + linkStart: false, }), '2': makeOntimeEvent({ id: '2', @@ -341,7 +356,7 @@ describe('generate()', () => { timeEnd: 601000, duration: 85801000, // <------------- value out of sync timeStrategy: TimeStrategy.LockEnd, - linkStart: '1', + linkStart: true, }), '3': makeOntimeEvent({ id: '3', @@ -349,51 +364,37 @@ describe('generate()', () => { timeEnd: 602000, duration: 0, timeStrategy: TimeStrategy.LockEnd, - linkStart: '2', + linkStart: true, }), }, }); const initResult = generate(rundown); - expect(initResult.rundown).toMatchObject({ + expect(initResult.entries).toMatchObject({ '1': { timeStart: 0, timeEnd: 600000, duration: 600000, timeStrategy: 'lock-duration', - linkStart: null, + linkStart: false, }, '2': { timeStart: 600000, timeEnd: 601000, duration: 1000, timeStrategy: 'lock-end', - linkStart: '1', + linkStart: true, }, '3': { timeStart: 601000, timeEnd: 602000, duration: 1000, timeStrategy: 'lock-end', - linkStart: '2', + linkStart: true, }, }); }); - it('deletes links if invalid', () => { - const rundown = makeRundown({ - order: ['1'], - entries: { - '1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }), - }, - }); - - const initResult = generate(rundown); - expect(initResult.order.length).toBe(1); - expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1); - expect(Object.keys(initResult.links).length).toBe(0); - }); - describe('custom properties feature', () => { it('creates a map of custom properties', () => { const customProperties: CustomFields = { @@ -433,8 +434,8 @@ describe('generate()', () => { lighting: ['1', '2'], sound: ['2'], }); - expect((initResult.rundown['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); - expect((initResult.rundown['2'] as OntimeEvent).custom).toMatchObject({ + expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' }); + expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 2 lx', sound: 'event 2 sound', }); @@ -442,6 +443,106 @@ describe('generate()', () => { }); }); +describe('generate() v4', () => { + describe('handle of event groups', () => { + it('correctly parses group metadata', () => { + const rundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['100', '200', '300'] }), + '100': makeOntimeEvent({ id: '100', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), + '200': makeOntimeEvent({ id: '200', timeStart: 200, timeEnd: 300, duration: 100 }), + '300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + const generatedRundown = generate(rundown); + + expect(generatedRundown.order).toMatchObject(['1']); + expect(generatedRundown.totalDuration).toBe(300); + expect(generatedRundown.totalDelay).toBe(0); + expect(generatedRundown.entries).toMatchObject({ + '1': { + type: SupportedEvent.Block, + events: ['100', '200', '300'], + startTime: 100, + endTime: 400, + duration: 300, + isFirstLinked: false, + numEvents: 3, + }, + '100': { type: SupportedEvent.Event, currentBlock: '1' }, + '200': { type: SupportedEvent.Event, currentBlock: '1' }, + '300': { type: SupportedEvent.Event, currentBlock: '1' }, + }); + }); + + it('treats groups as invisible for gap calculations', () => { + const rundown = makeRundown({ + order: ['0', '1', '2', '3'], + entries: { + '0': makeOntimeEvent({ id: '0', timeStart: 0, timeEnd: 10, duration: 10, linkStart: false }), + '1': makeOntimeBlock({ id: '1', events: ['101', '102', '103'] }), + '101': makeOntimeEvent({ id: '101', timeStart: 100, timeEnd: 200, duration: 100, linkStart: false }), + '102': makeOntimeEvent({ id: '102', timeStart: 200, timeEnd: 300, duration: 100, linkStart: true }), + '103': makeOntimeEvent({ id: '103', timeStart: 300, timeEnd: 400, duration: 100, linkStart: true }), + '2': makeOntimeBlock({ id: '2', events: ['201', '202', '203'] }), + '201': makeOntimeEvent({ id: '201', timeStart: 500, timeEnd: 600, duration: 100, linkStart: false }), + '202': makeOntimeEvent({ id: '202', timeStart: 600, timeEnd: 700, duration: 100, linkStart: true }), + '203': makeOntimeEvent({ id: '203', timeStart: 700, timeEnd: 800, duration: 100, linkStart: true }), + '3': makeOntimeBlock({ id: '3', events: ['301', '302', '303'] }), + '301': makeOntimeEvent({ id: '301', timeStart: 900, timeEnd: 1000, duration: 100, linkStart: false }), + '302': makeOntimeEvent({ id: '302', timeStart: 1000, timeEnd: 1100, duration: 100, linkStart: true }), + '303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }), + }, + }); + const generatedRundown = generate(rundown); + + expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']); + expect(generatedRundown.totalDuration).toBe(1200); + expect(generatedRundown.totalDelay).toBe(0); + expect(generatedRundown.entries).toMatchObject({ + '0': { type: SupportedEvent.Event, currentBlock: null }, + '1': { + type: SupportedEvent.Block, + events: ['101', '102', '103'], + startTime: 100, + endTime: 400, + duration: 300, + isFirstLinked: false, + numEvents: 3, + }, + '101': { currentBlock: '1', gap: 90, linkStart: false }, + '102': { currentBlock: '1' }, + '103': { currentBlock: '1' }, + '2': { + type: SupportedEvent.Block, + events: ['201', '202', '203'], + startTime: 500, + endTime: 800, + duration: 300, + isFirstLinked: false, + numEvents: 3, + }, + '201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false }, + '202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 }, + '203': { id: '203', timeStart: 700, timeEnd: 800, duration: 100 }, + '3': { + type: SupportedEvent.Block, + events: ['301', '302', '303'], + startTime: 900, + endTime: 1200, + duration: 300, + isFirstLinked: false, + numEvents: 3, + }, + '301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false }, + '302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 }, + '303': { id: '303', timeStart: 1100, timeEnd: 1200, duration: 100 }, + }); + }); + }); +}); + describe('add() mutation', () => { test('adds an event to the rundown', () => { const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); @@ -605,7 +706,7 @@ describe('swap() mutation', () => { }); }); -describe('custom fields', () => { +describe('custom fields flow', () => { describe('createCustomField()', () => { it('creates a field from given parameters', () => { const expected = { @@ -639,8 +740,7 @@ describe('custom fields', () => { }; const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' }); - expect(customFieldChangelog).toStrictEqual(new Map()); - + expect(customFieldChangelog).toStrictEqual({}); expect(customField).toStrictEqual(expected); }); @@ -689,10 +789,10 @@ describe('custom fields', () => { vi.useFakeTimers(); const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' }); expect(customField).toStrictEqual(expectedAfter); - expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']])); + expect(customFieldChangelog).toStrictEqual({ Video: 'AV' }); editCustomField('AV', { label: 'Video' }); vi.runAllTimers(); - expect(customFieldChangelog).toStrictEqual(new Map()); + expect(customFieldChangelog).toStrictEqual({}); vi.useRealTimers(); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts index b58a78a5cc..6486c2836a 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.utils.test.ts @@ -1,55 +1,13 @@ -import { - CustomFields, - EndAction, - OntimeEvent, - RundownEntries, - SupportedEvent, - TimeStrategy, - TimerType, -} from 'ontime-types'; +import { CustomFields, EndAction, OntimeEvent, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types'; import { addToCustomAssignment, calculateDayOffset, handleCustomField, - handleLink, hasChanges, isDataStale, } from '../rundownCache.utils.js'; import { MILLIS_PER_HOUR } from 'ontime-utils'; -import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; - -describe('handleLink()', () => { - it('populates data in object and updates link map', () => { - const entries: RundownEntries = { - '1': makeOntimeEvent({ id: '1', timeEnd: 100 }), - '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), - }; - - const mutableEvent = { ...entries[2] } as OntimeEvent; - const links = {}; - - const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links); - expect(result).toBeUndefined(); - expect(mutableEvent.timeStart).toBe(100); - expect(mutableEvent.linkStart).toBe('1'); - expect(links).toStrictEqual({ '1': '2' }); - }); - - it('removes link if linked event is not found', () => { - const entries: RundownEntries = { - '1': makeOntimeBlock({ id: '1' }), - '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), - }; - const mutableEvent = { ...entries[2] } as OntimeEvent; - const links = {}; - - const result = handleLink(mutableEvent, null, links); - expect(result).toBeUndefined(); - expect(mutableEvent.timeStart).toBe(0); - expect(mutableEvent.linkStart).toBe('true'); - expect(links).toStrictEqual({}); - }); -}); +import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; describe('addToCustomAssignment()', () => { it('adds given entry to assignedCustomFields', () => { @@ -77,18 +35,17 @@ describe('handleCustomField()', () => { label: 'sound', }, } as CustomFields; - const customFieldChangelog = new Map(); + const customFieldChangelog = {}; - // @ts-expect-error -- partial event for testing - const event: OntimeEvent = { + const event = makeOntimeEvent({ type: SupportedEvent.Event, id: '2', timeStart: 0, - linkStart: '1', + linkStart: true, custom: { lighting: 'on', }, - }; + }); const assignedCustomFields = {}; const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields); @@ -113,18 +70,17 @@ describe('handleCustomField()', () => { }, } as CustomFields; - const customFieldChangelog = new Map([['sound', 'video']]); + const customFieldChangelog = { sound: 'video' }; - // @ts-expect-error -- partial event for testing - const event: OntimeEvent = { + const event = makeOntimeEvent({ type: SupportedEvent.Event, id: '2', timeStart: 0, - linkStart: '1', + linkStart: true, custom: { sound: 'on', }, - }; + }); const assignedCustomFields = {}; const result = handleCustomField(customFields, customFieldChangelog, event, assignedCustomFields); @@ -149,17 +105,16 @@ describe('handleCustomField()', () => { }, } as CustomFields; - const customFieldChangelog = new Map([['field1', 'newField1']]); + const customFieldChangelog = { field1: 'newField1' }; - // @ts-expect-error -- partial event for testing - const mutableEvent: OntimeEvent = { + const mutableEvent = makeOntimeEvent({ type: SupportedEvent.Event, id: 'event1', custom: { field1: 'value1', field2: 'value2', }, - }; + }); const assignedCustomFields = {}; @@ -186,7 +141,7 @@ describe('isDataStale()', () => { { timeStart: 10 }, { timeEnd: 10 }, { duration: 10 }, - { linkStart: '1' }, + { linkStart: true }, { timerStrategy: TimeStrategy.LockDuration }, ]; diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index 8d0d7b462b..b5ed59e7cc 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -68,7 +68,7 @@ export function apply(delayId: EntryId, rundown: Rundown): Rundown { lastEntry = { ...currentEntry }; if (shouldUnlink) { - currentEntry.linkStart = null; + currentEntry.linkStart = false; shouldUnlink = false; } diff --git a/apps/server/src/services/rundown-service/rundown.types.ts b/apps/server/src/services/rundown-service/rundown.types.ts new file mode 100644 index 0000000000..5cc22809b0 --- /dev/null +++ b/apps/server/src/services/rundown-service/rundown.types.ts @@ -0,0 +1,20 @@ +import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types'; + +export type RundownMetadata = { + totalDelay: number; + totalDuration: number; + totalDays: number; + firstStart: MaybeNumber; + lastEnd: MaybeNumber; + + playableEventOrder: EntryId[]; + timedEventOrder: EntryId[]; + flatEventOrder: EntryId[]; + + /** + * Keep track of which custom fields are used. + * This will be handy for when we delete custom fields + * since we can clear the custom fields from every event where they are used + */ + assignedCustomFields: Record; +}; diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 99257e3fa0..8a90a115d4 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -4,33 +4,24 @@ import { CustomFields, EntryId, isOntimeBlock, - isOntimeDelay, isOntimeEvent, isPlayableEvent, - MaybeNumber, OntimeBlock, OntimeEvent, OntimeEntry, - PlayableEvent, Rundown, RundownEntries, } from 'ontime-types'; -import { - generateId, - insertAtIndex, - reorderArray, - swapEventData, - getTimeFromPrevious, - isNewLatest, - customFieldLabelToKey, -} from 'ontime-utils'; +import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.js'; +import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; -import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js'; +import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js'; +/** We hold the currently selected rundown and its metadata in memory */ let currentRundownId: EntryId = ''; let currentRundown: Rundown = { id: '', @@ -39,17 +30,26 @@ let currentRundown: Rundown = { entries: {}, revision: 0, }; -let persistedCustomFields: CustomFields = {}; +let projectCustomFields: CustomFields = {}; +let rundownMetadata: RundownMetadata = { + totalDelay: 0, + totalDuration: 0, + totalDays: 0, + firstStart: null, + lastEnd: null, + + playableEventOrder: [], + timedEventOrder: [], + flatEventOrder: [], + + assignedCustomFields: {}, +}; /** * Get the cached rundown without triggering regeneration */ export const getCurrentRundown = (): Rundown => currentRundown; -export const getCustomFields = (): CustomFields => persistedCustomFields; - -let playableEventsOrder: EntryId[] = []; -let timedEventsOrder: EntryId[] = []; -let flatIndexOrder: EntryId[] = []; +export const getCustomFields = (): CustomFields => projectCustomFields; /** * all mutating functions will set this value if there is a need for re-generation @@ -62,161 +62,111 @@ function setIsStale() { isStale = true; } -let totalDelay = 0; -let totalDuration = 0; -let totalDays = 0; -let firstStart: MaybeNumber = null; -let lastEnd: MaybeNumber = null; - -let links: Record = {}; - /** * Object that contains reference of renamed custom fields * Used to rename the custom fields in the events + * @private exported only to simplify testing * @example * { * oldLabel: newLabel * lighting: lx * } */ -export const customFieldChangelog = new Map(); - -/** - * Keep track of which custom fields are used. - * This will be handy for when we delete custom fields - */ -let assignedCustomFields: Record = {}; +export let customFieldChangelog: Record = {}; /** * Receives a rundown which will be processed and used as the new current rundown */ -export async function init(initialRundown: Rundown, customFields: Readonly) { +export async function init(initialRundown: Readonly, customFields: Readonly) { + // TODO: do we need to clone? currentRundown = structuredClone(initialRundown); currentRundownId = initialRundown.id; - persistedCustomFields = structuredClone(customFields); + projectCustomFields = structuredClone(customFields); generate(); + + // TODO: we may not need to persist this data since it should come from the database + // update the persisted data await getDataProvider().setRundown(currentRundownId, currentRundown); await getDataProvider().setCustomFields(customFields); } /** * Utility generate cache - * @private should not be called outside of `rundownCache.ts` + * @private should not be called outside of `rundownCache.ts`, exported for testing */ -export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) { +export function generate( + initialRundown: Readonly = currentRundown, + customFields: Readonly = projectCustomFields, +): ProcessedRundownMetadata { + // The stale state can only be cleared inside generate() function clearIsStale() { isStale = false; } - // we decided to re-write this dataset for every change - // instead of maintaining logic to update it - - assignedCustomFields = {}; - playableEventsOrder = []; - timedEventsOrder = []; - flatIndexOrder = []; - - links = {}; - firstStart = null; - lastEnd = null; - totalDuration = 0; - totalDays = 0; - totalDelay = 0; - - // temporary parsed rundown - const parsedEntries: RundownEntries = {}; - const parsedOrder: EntryId[] = []; - - /** A playableEvent from the previous iteration */ - let previousEntry: PlayableEvent | null = null; - /** The playableEvent most forwards in time processed so far */ - let lastEntry: PlayableEvent | null = null; + const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog); for (let i = 0; i < initialRundown.order.length; i++) { // we assign a reference to the current entry, this will be mutated in place const currentEntryId = initialRundown.order[i]; const currentEntry = initialRundown.entries[currentEntryId]; - flatIndexOrder.push(currentEntryId); - - if (isOntimeEvent(currentEntry)) { - currentEntry.delay = 0; - currentEntry.gap = 0; - timedEventsOrder.push(currentEntryId); - - // 1. handle links - mutates currentEntry and links - handleLink(currentEntry, previousEntry, links); - - // 2. handle custom fields - mutates currentEntry - handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields); - - totalDays += calculateDayOffset(currentEntry, lastEntry); - currentEntry.dayOffset = totalDays; - - // update rundown metadata, it only concerns playable events - if (isPlayableEvent(currentEntry)) { - playableEventsOrder.push(currentEntryId); - // fist start is always the first event - if (firstStart === null) { - firstStart = currentEntry.timeStart; - } - - currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry); - - if (currentEntry.gap === 0) { - // event starts on previous finish, we add its duration - totalDuration += currentEntry.duration; - } else if (currentEntry.gap > 0) { - // event has a gap, we add the gap and the duration - totalDuration += currentEntry.gap + currentEntry.duration; - } else if (currentEntry.gap < 0) { - // there is an overlap, we remove the overlap from the duration - // ensuring that the sum is not negative (ie: fully overlapped events) - // NOTE: we add the gap since it is a negative number - totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0); + const { processedEntry } = process(currentEntry, null); + + // if the event is a block, we process the nested entries + // the code here is a copy of the processing of top level events + if (isOntimeBlock(processedEntry)) { + let totalBlockDuration = 0; + let blockStartTime = null; + let blockEndTime = null; + let isFirstLinked = false; + + // check if the block contains events + for (let i = 0; i < processedEntry.events.length; i++) { + const nestedEntryId = processedEntry.events[i]; + const nestedEntry = initialRundown.entries[nestedEntryId]; + const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process( + nestedEntry, + processedEntry.id, + ); + + // we dont extract metadata of skipped events, + // if this is not a playable event there is nothing else to do + if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) { + continue; } - // remove eventual gaps from the accumulated delay - // we only affect positive delays (time forwards) - if (totalDelay > 0 && currentEntry.gap > 0) { - totalDelay = Math.max(totalDelay - currentEntry.gap, 0); + // first start is always the first event + if (blockStartTime === null) { + blockStartTime = processedNestedEntry.timeStart; + isFirstLinked = Boolean(processedNestedEntry.linkStart); } - // current event delay is the current accumulated delay - currentEntry.delay = totalDelay; - previousEntry = currentEntry; // lastEntry is the event with the latest end time - if (isNewLatest(currentEntry, lastEntry)) { - lastEntry = currentEntry; - } + blockEndTime = processedNestedData.lastEnd; + totalBlockDuration += processedNestedEntry.duration; } - } else if (isOntimeDelay(currentEntry)) { - // calculate delays - // !!! this must happen after handling the links - totalDelay += currentEntry.duration; - } else if (isOntimeBlock(currentEntry)) { - // calculate block - nothing yet - } else { - // unknown - type skip it - // this is needed to get the type guard working when we assign the entry to the rundown - continue; - } - // add id to order - parsedOrder.push(currentEntry.id); - // add entry to rundown - parsedEntries[currentEntry.id] = currentEntry; + // update block metadata + processedEntry.duration = totalBlockDuration; + processedEntry.startTime = blockStartTime; + processedEntry.endTime = blockEndTime; + processedEntry.isFirstLinked = isFirstLinked; + processedEntry.numEvents = processedEntry.events.length; + } } - lastEnd = lastEntry?.timeEnd ?? null; + const processedData = getMetadata(); clearIsStale(); - customFieldChangelog.clear(); + customFieldChangelog = {}; // update the cache values - currentRundown.entries = parsedEntries; - currentRundown.order = parsedOrder; + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data + const { entries, order, previousEvent, latestEvent, ...metadata } = processedData; + currentRundown.entries = entries; + currentRundown.order = order; + rundownMetadata = metadata; // The return value is used for testing - return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields }; + return processedData; } /** Returns an ID guaranteed to be unique */ @@ -271,33 +221,22 @@ export function get(): Readonly { entries: currentRundown.entries, order: currentRundown.order, revision: currentRundown.revision, - totalDelay, - totalDuration, + totalDelay: rundownMetadata.totalDelay, + totalDuration: rundownMetadata.totalDuration, }; } -export type RundownMetadata = { - firstStart: MaybeNumber; - lastEnd: MaybeNumber; - totalDelay: number; - totalDuration: number; - revision: number; -}; - /** * Returns calculated metadata from rundown * Will triggering regeneration if data is stale. */ -export function getMetadata(): Readonly { +export function getMetadata(): Readonly { if (isStale) { generate(); } return { - firstStart, - lastEnd, - totalDelay, - totalDuration, + ...rundownMetadata, revision: currentRundown.revision, }; } @@ -317,8 +256,8 @@ export function getEventOrder(): Readonly { } return { order: currentRundown.order, - timedEventsOrder, - playableEventsOrder, + timedEventsOrder: rundownMetadata.timedEventOrder, + playableEventsOrder: rundownMetadata.playableEventOrder, }; } @@ -536,11 +475,8 @@ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { * Utility for invalidating service cache if a custom field is used */ function invalidateIfUsed(label: CustomFieldLabel) { - if (label in assignedCustomFields) { - setIsStale(); - } // if the field was in use, we mark the cache as stale - if (label in assignedCustomFields) { + if (label in rundownMetadata.assignedCustomFields) { setIsStale(); } // ... and schedule a cache update @@ -556,7 +492,7 @@ function invalidateIfUsed(label: CustomFieldLabel) { */ function scheduleCustomFieldPersist() { setImmediate(async () => { - await getDataProvider().setCustomFields(persistedCustomFields); + await getDataProvider().setCustomFields(projectCustomFields); }); } @@ -572,29 +508,29 @@ export function createCustomField(field: CustomField): CustomFields { } // check if label already exists - const alreadyExists = Object.hasOwn(persistedCustomFields, key); + const alreadyExists = Object.hasOwn(projectCustomFields, key); if (alreadyExists) { throw new Error('Label already exists'); } // update object and persist - persistedCustomFields[key] = { label, type, colour }; + projectCustomFields[key] = { label, type, colour }; scheduleCustomFieldPersist(); - return persistedCustomFields; + return projectCustomFields; } /** * Edits an existing custom field in the database */ export function editCustomField(key: string, newField: Partial): CustomFields { - if (!(key in persistedCustomFields)) { + if (!(key in projectCustomFields)) { throw new Error('Could not find label'); } - const existingField = persistedCustomFields[key]; + const existingField = projectCustomFields[key]; if (newField.type !== undefined && existingField.type !== newField.type) { throw new Error('Change of field type is not allowed'); } @@ -607,29 +543,29 @@ export function editCustomField(key: string, newField: Partial): Cu if (newKey === null) { throw new Error('Unable to convert label to a valid key'); } - persistedCustomFields[newKey] = { ...existingField, ...newField }; + projectCustomFields[newKey] = { ...existingField, ...newField }; if (key !== newKey) { - delete persistedCustomFields[key]; - customFieldChangelog.set(key, newKey); + delete projectCustomFields[key]; + customFieldChangelog[key] = newKey; } scheduleCustomFieldPersist(); invalidateIfUsed(key); - return persistedCustomFields; + return projectCustomFields; } /** * Deletes a custom field from the database */ export function removeCustomField(label: string): CustomFields { - if (label in persistedCustomFields) { - delete persistedCustomFields[label]; + if (label in projectCustomFields) { + delete projectCustomFields[label]; } scheduleCustomFieldPersist(); invalidateIfUsed(label); - return persistedCustomFields; + return projectCustomFields; } diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 9eb6418cb9..ac62639113 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -1,38 +1,19 @@ -import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types'; -import { dayInMs, getLinkedTimes } from 'ontime-utils'; +import { + OntimeEvent, + CustomFieldLabel, + CustomFields, + OntimeEntry, + OntimeBaseEvent, + EntryId, + isOntimeEvent, + isPlayableEvent, + isOntimeDelay, + PlayableEvent, + RundownEntries, +} from 'ontime-types'; +import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; -/** - * Checks that link can be established (ie, events exist and are valid) - * and populates the time data from link - * With the current implementation, the links is always the previous playable event - * Mutates mutableEvent in place - * Mutates links in place - */ -export function handleLink( - mutableEvent: OntimeEvent, - previousEvent: OntimeEvent | null, - links: Record, -): void { - if (!mutableEvent.linkStart) { - return; - } - - /** - * If no previous event exist, we dont remove the link - * this means that the event will keep the behaviour in case a new event is added before - * However, we do add its ID to the links and prevent out-of-sync data - */ - if (!previousEvent) { - mutableEvent.linkStart = 'true'; - return; - } - - const timePatch = getLinkedTimes(mutableEvent, previousEvent); - mutableEvent.linkStart = previousEvent.id; - links[previousEvent.id] = mutableEvent.id; - // use object.assign to force mutation - Object.assign(mutableEvent, timePatch); -} +import type { RundownMetadata } from './rundown.types.js'; /** * Utility function to add an entry, mutates given assignedCustomFields in place @@ -52,19 +33,19 @@ export function addToCustomAssignment( /** * Sanitises custom fields and updates values if necessary - * Mudates in place mutableEvent and assignedCustomFields + * Mutates in place mutableEvent and assignedCustomFields */ export function handleCustomField( customFields: CustomFields, - customFieldChangelog: Map, + customFieldChangelog: Record, mutableEvent: OntimeEvent, assignedCustomFields: Record, ) { for (const field in mutableEvent.custom) { // rename the property if it is in the changelog - if (customFieldChangelog.has(field)) { + if (field in customFieldChangelog) { const oldData = mutableEvent.custom[field]; - const newLabel = customFieldChangelog.get(field) as string; // it os OK to cast to string here since we already checked that it existed + const newLabel = customFieldChangelog[field]; mutableEvent.custom[newLabel] = oldData; delete mutableEvent.custom[field]; @@ -101,17 +82,15 @@ enum RegenerateWhitelist { /** * given a patch, returns whether all keys are whitelisted - * @param path */ export function isDataStale(patch: Partial): boolean { - return Object.keys(patch).some((key) => !(key in RegenerateWhitelist)); + return Object.keys(patch).some(willCauseRegeneration); } /** * given a key, returns whether it is whitelisted - * @param path */ -export function willCauseRegeneration(key: keyof OntimeEvent): boolean { +export function willCauseRegeneration(key: string): boolean { return !(key in RegenerateWhitelist); } @@ -159,3 +138,143 @@ export function calculateDayOffset( return 0; } + +export type ProcessedRundownMetadata = RundownMetadata & { + entries: RundownEntries; + order: EntryId[]; + previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration + latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far +}; + +export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record) { + let rundownMeta: ProcessedRundownMetadata = { + totalDelay: 0, + totalDuration: 0, + totalDays: 0, + firstStart: null, + lastEnd: null, + + assignedCustomFields: {}, + playableEventOrder: [], + timedEventOrder: [], + flatEventOrder: [], + + entries: {}, + order: [], + previousEvent: null, + latestEvent: null, + }; + + function process( + entry: T, + childOfBlock: EntryId | null, + ): { processedData: ProcessedRundownMetadata; processedEntry: T } { + const data = processEntry(rundownMeta, customFields, customFieldChangelog, entry, childOfBlock); + rundownMeta = data.processedData; + return data; + } + + function getMetadata(): ProcessedRundownMetadata { + return rundownMeta; + } + + return { process, getMetadata }; +} + +function processEntry( + rundownMetadata: ProcessedRundownMetadata, + customFields: CustomFields, + customFieldChangelog: Record, + entry: T, + childOfBlock: EntryId | null, +): { processedData: ProcessedRundownMetadata; processedEntry: T } { + const processedData = { ...rundownMetadata }; + const currentEntry = structuredClone(entry); + processedData.flatEventOrder.push(currentEntry.id); + + if (isOntimeEvent(currentEntry)) { + if (!childOfBlock) { + processedData.timedEventOrder.push(currentEntry.id); + } + + /** + * 1.Checks that link can be established (ie, events exist and are valid) + * and populates the time data from link + * The linked event is always the previous playable event + * If no previous event exists, the link is removed + */ + if (currentEntry.linkStart) { + if (processedData.previousEvent) { + const timePatch = getLinkedTimes(currentEntry, processedData.previousEvent); + currentEntry.timeStart = timePatch.timeStart; + currentEntry.timeEnd = timePatch.timeEnd; + currentEntry.duration = timePatch.duration; + } else { + currentEntry.linkStart = false; + } + } + + // 2. handle custom fields - mutates currentEntry + handleCustomField(customFields, customFieldChangelog, currentEntry, processedData.assignedCustomFields); + + processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent); + currentEntry.dayOffset = processedData.totalDays; + currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events + currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events + currentEntry.currentBlock = childOfBlock; + + // update rundown metadata, it only concerns playable events + if (isPlayableEvent(currentEntry)) { + if (!childOfBlock) { + processedData.playableEventOrder.push(currentEntry.id); + } + + // first start is always the first event + if (processedData.firstStart === null) { + processedData.firstStart = currentEntry.timeStart; + } + + currentEntry.gap = getTimeFrom(currentEntry, processedData.latestEvent); + + if (currentEntry.gap === 0) { + // event starts on previous finish, we add its duration + processedData.totalDuration += currentEntry.duration; + } else if (currentEntry.gap > 0) { + // event has a gap, we add the gap and the duration + processedData.totalDuration += currentEntry.gap + currentEntry.duration; + } else if (currentEntry.gap < 0) { + // there is an overlap, we remove the overlap from the duration + // ensuring that the sum is not negative (ie: fully overlapped events) + // NOTE: we add the gap since it is a negative number + processedData.totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0); + } + + // remove eventual gaps from the accumulated delay + // we only affect positive delays (time forwards) + if (processedData.totalDelay > 0 && currentEntry.gap > 0) { + processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0); + } + // current event delay is the current accumulated delay + currentEntry.delay = processedData.totalDelay; + + // assign data for next iteration + processedData.previousEvent = currentEntry; + + // lastEntry is the event with the latest end time + if (isNewLatest(currentEntry, processedData.latestEvent)) { + processedData.latestEvent = currentEntry; + processedData.lastEnd = currentEntry.timeEnd; + } + } + } else if (isOntimeDelay(currentEntry)) { + // !!! this must happen after handling the links + processedData.totalDelay += currentEntry.duration; + } + + if (!childOfBlock) { + processedData.order.push(currentEntry.id); + } + processedData.entries[currentEntry.id] = currentEntry; + + return { processedData, processedEntry: currentEntry }; +} diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index 4e51dbca30..4f9b5ff23e 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -9,13 +9,15 @@ import { ImportMap, getErrorMessage } from 'ontime-utils'; import { sheets, type sheets_v4 } from '@googleapis/sheets'; import { Credentials, OAuth2Client } from 'google-auth-library'; +// TODO: rewrite logic to use fetch and remove dependency import got from 'got'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; -import { getRundown } from '../rundown-service/rundownUtils.js'; -import { getCustomFields } from '../rundown-service/rundownCache.js'; +import { parseRundowns } from '../../utils/parserFunctions.js'; + +import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js'; +import { getRundownOrThrow } from '../rundown-service/rundownUtils.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js'; import { catchCommonImportXlsxError } from './googleApi.utils.js'; diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index fbb7e26995..4a650c9089 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -28,7 +28,7 @@ describe('cellRequestFromEvent()', () => { timeStart: 46800000, timeEnd: 57600000, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, endAction: EndAction.None, timerType: TimerType.CountDown, countToEnd: false, @@ -81,7 +81,7 @@ describe('cellRequestFromEvent()', () => { countToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, isPublic: false, skip: false, colour: 'red', @@ -132,7 +132,7 @@ describe('cellRequestFromEvent()', () => { countToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, isPublic: true, skip: false, colour: 'red', @@ -181,7 +181,7 @@ describe('cellRequestFromEvent()', () => { timerType: TimerType.CountDown, countToEnd: false, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, duration: 10800000, isPublic: true, skip: false, @@ -218,7 +218,7 @@ describe('cellRequestFromEvent()', () => { countToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, isPublic: true, skip: false, colour: 'red', @@ -255,7 +255,7 @@ describe('cellRequestFromEvent()', () => { countToEnd: false, duration: 10800000, timeStrategy: TimeStrategy.LockEnd, - linkStart: null, + linkStart: false, isPublic: true, skip: false, colour: 'red', diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 3c619ec038..5ba8eb270a 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -31,9 +31,9 @@ describe('test parseDatabaseModel() with demo project (valid)', () => { const filteredDemoProject = structuredClone(demoDb); const { data } = parseDatabaseModel(filteredDemoProject); - it('has 16 events', () => { - expect(data.rundowns.demo.order.length).toBe(16); - expect(Object.keys(data.rundowns.demo.entries).length).toBe(16); + it('has 17 events with 12 top level events', () => { + expect(data.rundowns.default.order.length).toBe(12); + expect(Object.keys(data.rundowns.default.entries).length).toBe(17); }); it('is the same as the demo project since all data is valid', () => { @@ -663,7 +663,7 @@ describe('parseExcel()', () => { }); }); - it('parses link start and checks that is applicable', () => { + it('parses link start', () => { const testData = [ ['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'], ['4:30:00', '9:45:00', 'A', '', 'count-down'], @@ -688,22 +688,22 @@ describe('parseExcel()', () => { expect(result.rundown.entries).toMatchObject({ A: { - linkStart: null, + linkStart: false, }, B: { - linkStart: 'true', // <--- this will be populated by the cache generation + linkStart: true, }, C: { - linkStart: 'true', // <--- this will be populated by the cache generation + linkStart: true, }, D: { - linkStart: null, + linkStart: false, }, BLOCK: { type: SupportedEvent.Block, }, E: { - linkStart: 'true', // <--- this will be populated by the cache generation + linkStart: true, }, }); }); @@ -775,7 +775,7 @@ describe('parseExcel()', () => { expect(parsedData.rundown.entries['MEET3']).toMatchObject({ duration: 90 * MILLIS_PER_MINUTE, - linkStart: null, + linkStart: false, timeWarning: 11 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); @@ -783,7 +783,7 @@ describe('parseExcel()', () => { expect(parsedData.rundown.entries['MEET4']).toMatchObject({ duration: 30 * MILLIS_PER_MINUTE, timeWarning: 11 * MILLIS_PER_MINUTE, - linkStart: 'true', // if we get a boolean, we should just use that + linkStart: true, }); }); }); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index 8649b38a7a..01a0b56f22 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -12,6 +12,7 @@ import { parseViewSettings, sanitiseCustomFields, } from '../parserFunctions.js'; +import { makeOntimeBlock, makeOntimeEvent } from '../../services/rundown-service/__mocks__/rundown.mocks.js'; describe('parseRundowns()', () => { it('returns a default project rundown if nothing is given', () => { @@ -166,6 +167,43 @@ describe('parseRundown()', () => { 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'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.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'], + 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); + }); }); describe('parseProject()', () => { @@ -361,123 +399,3 @@ describe('sanitiseCustomFields()', () => { expect(sanitationResult).toStrictEqual(expectedCustomFields); }); }); - -describe('parseRundown() linking', () => { - it('returns linked events', () => { - const rundown: Rundown = { - id: '', - title: '', - revision: 1, - order: ['1', '2'], - entries: { - '1': { - id: '1', - type: SupportedEvent.Event, - skip: false, - } as OntimeEvent, - '2': { - id: '2', - type: SupportedEvent.Event, - linkStart: 'true', - skip: false, - } as OntimeEvent, - }, - }; - - const result = parseRundown(rundown, {}); - expect(result).toMatchObject({ - order: ['1', '2'], - entries: { - '2': { - linkStart: '1', - }, - }, - }); - }); - - it('returns unlinked if no previous', () => { - const rundown: Rundown = { - id: '', - title: '', - revision: 1, - order: ['1', '2'], - entries: { - '2': { - id: '2', - type: SupportedEvent.Event, - linkStart: 'true', - skip: false, - } as OntimeEvent, - }, - }; - - const result = parseRundown(rundown, {}); - expect(result).toMatchObject({ - order: ['2'], - entries: { - '2': { - linkStart: null, - }, - }, - }); - }); - - it('returns linked events past blocks and delays', () => { - const rundown: Rundown = { - id: '', - title: '', - revision: 1, - order: ['1', 'delay1', '2', 'block1', '3'], - entries: { - '1': { - id: '1', - type: SupportedEvent.Event, - skip: false, - } as OntimeEvent, - delay1: { - id: 'delay1', - type: SupportedEvent.Delay, - duration: 0, - }, - '2': { - id: '2', - type: SupportedEvent.Event, - linkStart: 'true', - skip: false, - } as OntimeEvent, - block1: { - id: 'block1', - type: SupportedEvent.Block, - title: '', - } as OntimeBlock, - '3': { - id: '3', - type: SupportedEvent.Event, - linkStart: 'true', - skip: false, - } as OntimeEvent, - }, - }; - - const result = parseRundown(rundown, {}); - expect(result).toMatchObject({ - order: rundown.order, - entries: { - '1': { - id: '1', - cue: '1', - }, - '2': { - id: '2', - cue: '2', - linkStart: '1', - }, - '3': { - id: '3', - cue: '3', - linkStart: '2', - }, - }, - }); - }); -}); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index e1c35fefaf..753904bf40 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -6,7 +6,6 @@ import { type ImportMap, isKnownTimerType, validateEndAction, - validateLinkStart, validateTimerType, validateTimes, } from 'ontime-utils'; @@ -246,7 +245,7 @@ export const parseExcel = ( } else if (j === timeStartIndex) { entry.timeStart = parseExcelDate(column); } else if (j === linkStartIndex) { - entry.linkStart = parseBooleanString(column) ? 'true' : null; + entry.linkStart = parseBooleanString(column); } else if (j === timeEndIndex) { entry.timeEnd = parseExcelDate(column); } else if (j === durationIndex) { @@ -412,7 +411,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial { await page.getByRole('button', { name: 'Delete all' }).click(); await page.getByRole('button', { name: 'toggle settings' }).click(); - await page.getByRole('button', { name: 'Project', exact: true }).click(); + await page.getByRole('button', { name: 'Manage projects' }).click(); // workaround to upload file on hidden input // https://playwright.dev/docs/api/class-filechooser @@ -39,14 +39,14 @@ test('project file download', async ({ page }) => { await page.goto('http://localhost:4001/editor'); await page.getByRole('button', { name: 'toggle settings' }).click(); - await page.getByRole('button', { name: 'Project', exact: true }).click(); + await page.getByRole('button', { name: 'Manage projects' }).click(); // workaround to download // https://playwright.dev/docs/api/class-download const downloadPromise = page.waitForEvent('download'); await page - .getByRole('row', { name: /^e2e-test-db/ }) + .getByRole('row', { name: /.*currently loaded/i }) .getByLabel('Options') .click(); await page.getByRole('menuitem', { name: 'Download' }).click(); diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index 1c69921c0f..f76ac25d65 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -1,13 +1,13 @@ import { test, expect } from '@playwright/test'; -test('Copy Past', async ({ page }) => { +test('Copy-paste', async ({ page }) => { await page.goto('http://localhost:4001/rundown'); // clear rundown await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); - //create event + // create event await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByTestId('entry-1').click(); await page.getByLabel('Cue', { exact: true }).click(); @@ -18,22 +18,22 @@ test('Copy Past', async ({ page }) => { await page.getByTestId('block__title').fill('test'); await page.getByTestId('block__title').press('Enter'); - //copy past below + // copy paste below await page.locator('div').filter({ hasText: /^4$/ }).click(); await page.locator('div').filter({ hasText: /^4$/ }).press('Control+c'); await page.locator('div').filter({ hasText: /^4$/ }).press('Control+v'); - //assert + // assert await expect(page.getByTestId('entry-2')).toBeVisible(); await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test'); await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5'); - //copy past above + // copy paste above await page.locator('div').filter({ hasText: /^5$/ }).click(); await page.locator('div').filter({ hasText: /^5$/ }).press('Control+c'); await page.locator('div').filter({ hasText: /^5$/ }).press('Control+Shift+v'); - //assert + // assert await expect(page.getByTestId('entry-2')).toBeVisible(); await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test'); await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('4.1'); @@ -46,17 +46,17 @@ test('Move', async ({ page }) => { await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); - //create events + // create events await page.getByRole('button', { name: 'Create Event' }).click(); await page.getByRole('button', { name: 'Event' }).nth(4).click(); await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click(); - //copy move down + // copy move down await page.getByTestId('entry-1').locator('#event-block').getByText('1').click(); await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Control+ArrowDown'); await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('1'); - //copy move up + // copy move up await page.getByTestId('entry-3').locator('#event-block').getByText('3').click(); await page.getByTestId('entry-3').locator('#event-block div').filter({ hasText: '3' }).press('Alt+Control+ArrowUp'); await page.getByTestId('entry-2').locator('div').filter({ hasText: /^3$/ }).press('Alt+Control+ArrowUp'); @@ -70,18 +70,25 @@ test('Add block', async ({ page }) => { await page.getByRole('button', { name: 'Clear rundown' }).click(); await page.getByRole('button', { name: 'Delete all' }).click(); - //create events + // create events await page.getByRole('button', { name: 'Create Event' }).click(); + await page.getByPlaceholder(/event title/i).fill('test'); await page.getByTestId('entry-1').click(); - await page.getByTestId('block__title').press('Escape'); - //add block below + // add block below await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+B'); - await expect(page.getByPlaceholder('Block title')).toBeVisible(); + await page.getByPlaceholder(/block title/i).fill('block below'); - //add block above + // add block above await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+B'); - await expect(page.getByTestId('entry-0').getByTestId('block__title')).toBeVisible(); + await page + .getByPlaceholder(/block title/i) + .first() + .fill('block above'); + + await expect(page.getByTestId(/block__title/i).first()).toHaveValue('block above'); + await expect(page.getByTestId(/block__title/i).nth(2)).toHaveValue('block below'); + await expect(page.getByTestId('entry-1').getByTestId(/block__title/)).toHaveValue('test'); }); test('Add delay', async ({ page }) => { diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index c30468f976..67495d7e50 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -31,7 +31,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 36000000, "timeEnd": 37200000, @@ -60,7 +60,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 37500000, "timeEnd": 38700000, @@ -89,7 +89,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 39000000, "timeEnd": 40200000, @@ -118,7 +118,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 40500000, "timeEnd": 41700000, @@ -147,7 +147,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 42000000, "timeEnd": 43200000, @@ -192,7 +192,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 47100000, "timeEnd": 48300000, @@ -221,7 +221,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 48600000, "timeEnd": 49800000, @@ -250,7 +250,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 50100000, "timeEnd": 51300000, @@ -279,7 +279,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 51600000, "timeEnd": 52800000, @@ -308,7 +308,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 53100000, "timeEnd": 54300000, @@ -353,7 +353,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 56100000, "timeEnd": 57300000, @@ -382,7 +382,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 57600000, "timeEnd": 58800000, @@ -411,7 +411,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 59100000, "timeEnd": 60300000, @@ -440,7 +440,7 @@ "endAction": "none", "timerType": "count-down", "countToEnd": false, - "linkStart": null, + "linkStart": false, "timeStrategy": "lock-end", "timeStart": 60600000, "timeEnd": 61800000, diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 5fd073071e..0db7a1f8f6 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -1,4 +1,4 @@ -import type { EndAction, EntryCustomFields, MaybeNumber, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js'; +import type { EndAction, EntryCustomFields, MaybeNumber, TimerType, TimeStrategy, Trigger } from '../../index.js'; export type EntryId = string; @@ -43,7 +43,7 @@ export type OntimeEvent = OntimeBaseEvent & { endAction: EndAction; timerType: TimerType; countToEnd: boolean; - linkStart: MaybeString; // ID of event to link to + linkStart: boolean; timeStrategy: TimeStrategy; timeStart: number; timeEnd: number; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 9ef4e100f9..46f1b6c96f 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -1,6 +1,6 @@ // runtime utils export { validatePlayback } from './src/validate-action/validatePlayback.js'; -export { isKnownTimerType, validateLinkStart, validateTimeStrategy } from './src/validate-events/validateEvent.js'; +export { isKnownTimerType, validateTimeStrategy } from './src/validate-events/validateEvent.js'; export { calculateDuration, getLinkedTimes, validateTimes } from './src/validate-times/validateTimes.js'; // rundown utils @@ -78,7 +78,7 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali // feature business logic - rundown export { checkIsNow } from './src/date-utils/checkIsNow.js'; export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js'; -export { getTimeFromPrevious } from './src/date-utils/getTimeFromPrevious.js'; +export { getTimeFrom } from './src/date-utils/getTimeFrom.js'; export { isNewLatest } from './src/date-utils/isNewLatest.js'; // feature business logic - spreadsheet import diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts b/packages/utils/src/date-utils/getTimeFrom.test.ts similarity index 76% rename from packages/utils/src/date-utils/getTimeFromPrevious.test.ts rename to packages/utils/src/date-utils/getTimeFrom.test.ts index a8633a2e05..8b2946ab3a 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.test.ts +++ b/packages/utils/src/date-utils/getTimeFrom.test.ts @@ -1,11 +1,11 @@ import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils'; -import { getTimeFromPrevious } from './getTimeFromPrevious'; +import { getTimeFrom } from './getTimeFrom'; -describe('getTimeFromPrevious', () => { +describe('getTimeFrom', () => { it('returns the time elapsed (gap or overlap) from the previous', () => { const expected = 75600000 - 71700000; // current start - previousEnd expect( - getTimeFromPrevious( + getTimeFrom( { timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 }, { timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 }, ), @@ -14,22 +14,18 @@ describe('getTimeFromPrevious', () => { it('accounts for partially overlapping events', () => { const expected = -1; - expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe( - expected, - ); + expect(getTimeFrom({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(expected); }); it('accounts for events that are fully contained', () => { const expected = -6; - expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe( - expected, - ); + expect(getTimeFrom({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(expected); }); it('fully overlapping events are the next day', () => { const expected = dayInMs - 2 * MILLIS_PER_HOUR; expect( - getTimeFromPrevious( + getTimeFrom( { timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 }, { timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }, ), @@ -39,7 +35,7 @@ describe('getTimeFromPrevious', () => { it('accounts for events that are the day after', () => { const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart); expect( - getTimeFromPrevious( + getTimeFrom( { timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 }, { timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 }, ), @@ -49,7 +45,7 @@ describe('getTimeFromPrevious', () => { it('accounts for events that cross midnight', () => { const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart); expect( - getTimeFromPrevious( + getTimeFrom( { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 }, { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }, ), diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.ts b/packages/utils/src/date-utils/getTimeFrom.ts similarity index 91% rename from packages/utils/src/date-utils/getTimeFromPrevious.ts rename to packages/utils/src/date-utils/getTimeFrom.ts index 4f22b35d5b..d29efd30e2 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.ts +++ b/packages/utils/src/date-utils/getTimeFrom.ts @@ -3,9 +3,9 @@ import type { OntimeEvent } from 'ontime-types'; import { dayInMs } from './conversionUtils.js'; /** - * Utility returns the gap from previous event + * Utility returns the gap from a previous given event */ -export function getTimeFromPrevious( +export function getTimeFrom( current: Pick, previous: Pick | null, ): number { diff --git a/packages/utils/src/validate-events/validateEvent.ts b/packages/utils/src/validate-events/validateEvent.ts index f6601af5dc..304d563fa5 100644 --- a/packages/utils/src/validate-events/validateEvent.ts +++ b/packages/utils/src/validate-events/validateEvent.ts @@ -1,19 +1,5 @@ -import type { MaybeString } from 'ontime-types'; import { EndAction, TimerType, TimeStrategy } from 'ontime-types'; -/** - * Check if a given value is a valid type linkStart, returns the fallback otherwise - * linkStart can be a string (id of an event to link) or null (unlinked) - * @param {MaybeString} maybeLinkStart - * @returns {MaybeString} - */ -export function validateLinkStart(maybeLinkStart: unknown, fallback: MaybeString = null): MaybeString { - if (typeof maybeLinkStart === 'string' || maybeLinkStart === null) { - return maybeLinkStart as MaybeString; - } - return fallback; -} - /** * Check if a given value is a valid time strategy, returns the fallback otherwise * @param {TimeStrategy} maybeTimeStrategy diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0fb7ef96..f5f9e688c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,8 +111,8 @@ importers: specifier: ^5.0.28 version: 5.0.28 '@mantine/hooks': - specifier: ^7.13.3 - version: 7.13.3(react@18.3.1) + specifier: ^7.17.2 + version: 7.17.2(react@18.3.1) '@sentry/react': specifier: ^8.43.0 version: 8.45.0(react@18.3.1) @@ -1653,10 +1653,10 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mantine/hooks@7.13.3': - resolution: {integrity: sha512-r2c+Z8CdvPKFeOwg6mSJmxOp9K/ave5ZFR7eJbgv4wQU8K1CAS5f5ven9K5uUX8Vf9B5dFnSaSgYp9UY3vOWTw==} + '@mantine/hooks@7.17.2': + resolution: {integrity: sha512-tbErVcGZu0E4dSmE6N0k6Tv1y9R3SQmmQgwqorcc+guEgKMdamc36lucZGlJnSGUmGj+WLUgELkEQ0asdfYBDA==} peerDependencies: - react: ^18.2.0 + react: ^18.x || ^19.x '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -6456,7 +6456,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mantine/hooks@7.13.3(react@18.3.1)': + '@mantine/hooks@7.17.2(react@18.3.1)': dependencies: react: 18.3.1 From 402d10055b3cbb7b8d7605fd674054e07eeb717b Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 29 Mar 2025 21:57:30 +0100 Subject: [PATCH 11/49] refactor: improve project loading --- apps/client/src/common/models/Info.ts | 2 +- .../src/common/models/OntimeSettings.ts | 3 +- apps/electron/src/main.js | 4 +- apps/server/src/app.ts | 12 +- .../src/classes/data-provider/DataProvider.ts | 3 + .../__tests__/DataProvider.utils.test.ts | 1 - apps/server/src/models/dataModel.ts | 1 - apps/server/src/models/demoProject.ts | 1 - .../project-service/ProjectService.ts | 166 +++++++----------- .../rundown-service/RundownService.ts | 4 +- .../__tests__/rundownCache.test.ts | 35 ++-- .../services/rundown-service/rundownCache.ts | 54 +++--- .../services/rundown-service/rundownUtils.ts | 10 +- .../server/src/utils/__tests__/parser.test.ts | 3 - .../utils/__tests__/parserFunctions.test.ts | 5 +- apps/server/src/utils/parserFunctions.ts | 9 +- .../src/definitions/core/Settings.type.ts | 1 - 17 files changed, 137 insertions(+), 177 deletions(-) diff --git a/apps/client/src/common/models/Info.ts b/apps/client/src/common/models/Info.ts index d05acdd2b0..6d6dd09a33 100644 --- a/apps/client/src/common/models/Info.ts +++ b/apps/client/src/common/models/Info.ts @@ -2,7 +2,7 @@ import { GetInfo } from 'ontime-types'; export const ontimePlaceholderInfo: GetInfo = { networkInterfaces: [], - version: '2.0.0', + version: '4.0.0', serverPort: 4001, publicDir: '', }; diff --git a/apps/client/src/common/models/OntimeSettings.ts b/apps/client/src/common/models/OntimeSettings.ts index bb26409da2..662a935c6b 100644 --- a/apps/client/src/common/models/OntimeSettings.ts +++ b/apps/client/src/common/models/OntimeSettings.ts @@ -1,8 +1,7 @@ import { Settings } from 'ontime-types'; export const ontimePlaceholderSettings: Settings = { - app: 'ontime', - version: '2.0.0', + version: '4.0.0', serverPort: 4001, editorKey: null, operatorKey: 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/src/app.ts b/apps/server/src/app.ts index 423e782acc..954ab6c32f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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(); diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 8ddb456959..4793a123dd 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -23,6 +23,9 @@ type ReadonlyPromise = Promise>; let db = {} as Low; +/** + * 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'); diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts index c9c5494eb8..c8bda5b236 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts @@ -67,7 +67,6 @@ describe('safeMerge', () => { } as Settings, }); expect(mergedData.settings).toStrictEqual({ - app: 'ontime', version: 'new', serverPort: 3000, operatorKey: null, diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 8b16c8b04b..c7157cb2fc 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -23,7 +23,6 @@ export const dbModel: DatabaseModel = { projectLogo: null, }, settings: { - app: 'ontime', version: ONTIME_VERSION, serverPort: 4001, editorKey: null, diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index de0527f1b0..28b9915718 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -495,7 +495,6 @@ export const demoDb: DatabaseModel = { projectLogo: null, }, settings: { - app: 'ontime', version: '-', serverPort: 4001, editorKey: null, diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index dfc56281cf..f80228e42f 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -31,6 +31,7 @@ import { setLastLoadedProject, } from '../app-state-service/AppStateService.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; +import { getFirstRundown } from '../rundown-service/rundownUtils.js'; import { copyCorruptFile, @@ -40,7 +41,6 @@ import { moveCorruptFile, parseJsonFile, } from './projectServiceUtils.js'; -import { getFirstRundown } from '../rundown-service/rundownUtils.js'; type ProjectState = | { @@ -70,38 +70,58 @@ function init() { export async function getCurrentProject(): Promise<{ filename: string; pathToFile: string }> { if (currentProjectState.status === 'PENDING') { - const lastLoadedProject = await initialiseProject(); - currentProjectState = { - status: 'INITIALIZED', - currentProjectName: lastLoadedProject, - }; + await initialiseProject(); } - const pathToFile = getPathToProject(currentProjectState.currentProjectName); + // we know the project is loaded since we force initialisation above + const pathToFile = getPathToProject(currentProjectState.currentProjectName as string); - return { filename: currentProjectState.currentProjectName, pathToFile }; + return { filename: currentProjectState.currentProjectName as string, pathToFile }; } /** - * Loads the demo project + * Private function loads a project file and handles necessary side effects */ -export async function loadDemoProject(): Promise { - const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, config.demoProject); - await initPersistence(getPathToProject(pathToNewFile), demoDb); +async function loadProject(projectData: DatabaseModel, projectName: string) { + // we need to make sure the file name is unique in the projects directory + const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, projectName); + + // change LowDB to point to new file + await initPersistence(getPathToProject(pathToNewFile), projectData); + logger.info(LogOrigin.Server, `Loaded project ${projectName}`); + + // stop the runtime service + runtimeService.stop(); + + // load the first rundown in the project + const firstRundown = getFirstRundown(projectData.rundowns); + await initRundown(firstRundown, projectData.customFields); + + // persist the project selection const newName = getFileNameFromPath(pathToNewFile); await setLastLoadedProject(newName); + + // update the service state + currentProjectState = { + status: 'INITIALIZED', + currentProjectName: newName, + }; + return newName; } +/** + * Loads the demo project + */ +export async function loadDemoProject(): Promise { + return loadProject(demoDb, config.demoProject); +} + /** * Private function loads a new, empty project * to be composed in the loading functions */ async function loadNewProject(): Promise { - const pathToNewFile = generateUniqueFileName(publicDir.projectsDir, config.newProject); - await initPersistence(getPathToProject(pathToNewFile), dbModel); - const newName = getFileNameFromPath(pathToNewFile); - await setLastLoadedProject(newName); - return newName; + return loadProject(dbModel, config.newProject); } /** @@ -129,46 +149,32 @@ export async function initialiseProject(): Promise { // check what was loaded before const previousProject = await getLastLoadedProject(); + // in normal circumstances we dont have a previous project if it is the first app start + // in which case we want to load a demo project if (!previousProject) { return loadDemoProject(); } - - // try and load the previous project - const filePath = doesProjectExist(previousProject); - if (filePath === null) { - logger.warning(LogOrigin.Server, `Previous project file ${previousProject} not found`); - return loadNewProject(); - } - try { - const fileData = await parseJsonFile(filePath); - const result = parseDatabaseModel(fileData); - let parsedFileName = previousProject; - let parsedFilePath = filePath; - - if (result.errors.length > 0) { - logger.warning(LogOrigin.Server, 'Project loaded with errors'); - parsedFileName = await handleCorruptedFile(filePath, previousProject); - parsedFilePath = getPathToProject(parsedFileName); - } - - await initPersistence(parsedFilePath, result.data); - await setLastLoadedProject(parsedFileName); - return parsedFileName; + const projectName = await loadProjectFile(previousProject); + return projectName; } catch (error) { + // if we are here, most likely the json parsing failed and the file is corrupt logger.warning(LogOrigin.Server, `Unable to load previous project ${previousProject}: ${getErrorMessage(error)}`); - await moveCorruptFile(filePath, previousProject).catch((_) => { + try { + const pathToFile = getPathToProject(previousProject); + await moveCorruptFile(pathToFile, previousProject); + } catch (_) { /* while we have to catch the error, we dont need to handle it */ - }); - - return loadNewProject(); + } } + + return loadNewProject(); } /** * Loads a data from a file into the runtime */ -export async function loadProjectFile(name: string) { +export async function loadProjectFile(name: string): Promise { const filePath = doesProjectExist(name); if (filePath === null) { throw new Error('Project file not found'); @@ -178,31 +184,14 @@ export async function loadProjectFile(name: string) { const fileData = await parseJsonFile(filePath); const result = parseDatabaseModel(fileData); let parsedFileName = name; - let parsedFilePath = filePath; if (result.errors.length > 0) { logger.warning(LogOrigin.Server, 'Project loaded with errors'); parsedFileName = await handleCorruptedFile(filePath, name); - parsedFilePath = getPathToProject(parsedFileName); } - // change LowDB to point to new file - await initPersistence(parsedFilePath, result.data); - logger.info(LogOrigin.Server, `Loaded project ${parsedFileName}`); - - // persist the project selection - await setLastLoadedProject(parsedFileName); - - // since load happens at runtime, we need to update the services that depend on the data - - // apply data model - runtimeService.stop(); - - const { rundowns, customFields } = result.data; - - // apply the rundown - const firstRundown = getFirstRundown(rundowns); - await initRundown(firstRundown, customFields); + const projectName = await loadProject(result.data, parsedFileName); + return projectName; } /** @@ -239,7 +228,7 @@ export async function duplicateProjectFile(originalFile: string, newFilename: st /** * Renames an existing project file */ -export async function renameProjectFile(originalFile: string, newFilename: string) { +export async function renameProjectFile(originalFile: string, newFilename: string): Promise { const projectFilePath = doesProjectExist(originalFile); if (projectFilePath === null) { throw new Error('Project file not found'); @@ -257,50 +246,23 @@ export async function renameProjectFile(originalFile: string, newFilename: strin const isLoaded = await isLastLoadedProject(originalFile); if (isLoaded) { const fileData = await parseJsonFile(pathToRenamed); - const result = parseDatabaseModel(fileData); - - // change LowDB to point to new file - await initPersistence(pathToRenamed, result.data); - logger.info(LogOrigin.Server, `Loaded project ${newFilename}`); - - // persist the project selection - await setLastLoadedProject(newFilename); - - // apply data model - runtimeService.stop(); + const projectData = parseDatabaseModel(fileData); - const { rundowns, customFields } = result.data; - - // apply the rundown - const firstRundown = getFirstRundown(rundowns); - await initRundown(firstRundown, customFields); + const newFileName = await loadProject(projectData.data, newFilename); + return newFileName; } + return newFilename; } /** * Creates a new project file and applies its result */ -export async function createProject(filename: string, initialData: Partial) { +export async function createProject(filename: string, initialData: Partial): Promise { const data = safeMerge(dbModel, initialData); const fileNameWithExtension = ensureJsonExtension(filename); - const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension); - const newFile = getPathToProject(uniqueFileName); - - // change LowDB to point to new file - await initPersistence(newFile, data); - - // apply data to running services - // we dont need to parse since we are creating a new file - await patchCurrentProject(data); - - // update app state to point to new value - setLastLoadedProject(uniqueFileName); - - // update the service state - currentProjectState.currentProjectName = uniqueFileName; - - return uniqueFileName; + const newFilename = await loadProject(data, fileNameWithExtension); + return newFilename; } /** @@ -338,7 +300,7 @@ export async function patchCurrentProject(data: Partial) { * We currently ignore all other rundowns */ const firstRundown = getFirstRundown(result.rundowns); - initRundown(firstRundown, result.customFields); + await initRundown(firstRundown, result.customFields); } const updatedData = await getDataProvider().getData(); @@ -346,17 +308,13 @@ export async function patchCurrentProject(data: Partial) { } /** - * Changes the title of a project + * Changes the current project data * it handles invalidating the necessary data */ export async function editCurrentProjectData(newData: Partial) { const currentProjectData = getDataProvider().getProjectData(); const updatedProjectData = await getDataProvider().setProjectData(newData); - if (currentProjectData.title !== updatedProjectData.title) { - // something - } - // Delete the old logo if the logo has been removed if (!updatedProjectData.projectLogo && currentProjectData.projectLogo) { const filePath = join(publicDir.logoDir, currentProjectData.projectLogo); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index e32e5c7a24..cb6e1e34b6 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -282,8 +282,8 @@ function notifyChanges(options: NotifyChangesOptions) { } /** - * Overrides the rundown with the given - * @param rundown + * Sets a new rundown in the cache + * and marks it as the currently loaded one */ export async function initRundown(rundown: Readonly, customFields: Readonly) { await cache.init(rundown, customFields); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 4ef18fc416..55348b7593 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -34,11 +34,10 @@ beforeAll(() => { describe('generate()', () => { test('benchmark function execution time', () => { - const rundown = demoDb.rundowns.default; const t1 = performance.now(); let result: ProcessedRundownMetadata | null = null; for (let i = 0; i < 100; i++) { - result = generate(rundown); + result = generate(demoDb.rundowns.default, demoDb.customFields); } const t2 = performance.now(); console.warn( @@ -60,7 +59,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(3); expect(initResult.order).toStrictEqual(['1', '2', '3']); expect(initResult.entries['1'].type).toBe(SupportedEvent.Event); @@ -77,7 +76,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(2); expect((initResult.entries['2'] as OntimeEvent).delay).toBe(100); expect(initResult.totalDelay).toBe(100); @@ -97,7 +96,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(7); expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); expect((initResult.entries['2'] as OntimeEvent).delay).toBe(200); @@ -117,7 +116,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start }); @@ -132,7 +131,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start }); @@ -167,7 +166,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start }); @@ -185,7 +184,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(7); expect((initResult.entries['1'] as OntimeEvent).delay).toBe(0); expect((initResult.entries['2'] as OntimeEvent).delay).toBe(-200); @@ -227,7 +226,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(5); expect((initResult.entries['2'] as OntimeEvent).timeStart).toBe(2); expect((initResult.entries['2'] as OntimeEvent).timeEnd).toBe(12); @@ -248,7 +247,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(3); expect((initResult.entries['3'] as OntimeEvent).timeStart).toBe(2); }); @@ -264,7 +263,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); @@ -280,7 +279,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); @@ -310,7 +309,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); }); @@ -333,7 +332,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); expect(initResult.totalDuration).toBe(expectedDuration); }); @@ -369,7 +368,7 @@ describe('generate()', () => { }, }); - const initResult = generate(rundown); + const initResult = generate(rundown, {}); expect(initResult.entries).toMatchObject({ '1': { timeStart: 0, @@ -455,7 +454,7 @@ describe('generate() v4', () => { '300': makeOntimeEvent({ id: '300', timeStart: 300, timeEnd: 400, duration: 100 }), }, }); - const generatedRundown = generate(rundown); + const generatedRundown = generate(rundown, {}); expect(generatedRundown.order).toMatchObject(['1']); expect(generatedRundown.totalDuration).toBe(300); @@ -495,7 +494,7 @@ describe('generate() v4', () => { '303': makeOntimeEvent({ id: '303', timeStart: 1100, timeEnd: 1200, duration: 100, linkStart: true }), }, }); - const generatedRundown = generate(rundown); + const generatedRundown = generate(rundown, {}); expect(generatedRundown.order).toMatchObject(['0', '1', '2', '3']); expect(generatedRundown.totalDuration).toBe(1200); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 8a90a115d4..56ef348b27 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -21,7 +21,6 @@ import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js'; -/** We hold the currently selected rundown and its metadata in memory */ let currentRundownId: EntryId = ''; let currentRundown: Rundown = { id: '', @@ -78,16 +77,14 @@ export let customFieldChangelog: Record = {}; * Receives a rundown which will be processed and used as the new current rundown */ export async function init(initialRundown: Readonly, customFields: Readonly) { - // TODO: do we need to clone? + // we clone this objects since we use mutating logic in the cache currentRundown = structuredClone(initialRundown); currentRundownId = initialRundown.id; projectCustomFields = structuredClone(customFields); - generate(); - // TODO: we may not need to persist this data since it should come from the database - // update the persisted data - await getDataProvider().setRundown(currentRundownId, currentRundown); - await getDataProvider().setCustomFields(customFields); + updateCache(); + + currentRundownId; } /** @@ -95,14 +92,9 @@ export async function init(initialRundown: Readonly, customFields: Read * @private should not be called outside of `rundownCache.ts`, exported for testing */ export function generate( - initialRundown: Readonly = currentRundown, - customFields: Readonly = projectCustomFields, + initialRundown: Readonly, + customFields: Readonly, ): ProcessedRundownMetadata { - // The stale state can only be cleared inside generate() - function clearIsStale() { - isStale = false; - } - const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog); for (let i = 0; i < initialRundown.order.length; i++) { @@ -154,9 +146,18 @@ export function generate( } } - const processedData = getMetadata(); - clearIsStale(); - customFieldChangelog = {}; + return getMetadata(); +} + +/** + * Runs the generate function in the currently loaded rundown and updates caches + */ +export function updateCache() { + // The stale state can only be cleared inside updateCache() + function clearIsStale() { + isStale = false; + } + const processedData = generate(currentRundown, projectCustomFields); // update the cache values // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data @@ -164,15 +165,14 @@ export function generate( currentRundown.entries = entries; currentRundown.order = order; rundownMetadata = metadata; - - // The return value is used for testing - return processedData; + clearIsStale(); + customFieldChangelog = {}; } /** Returns an ID guaranteed to be unique */ export function getUniqueId(): string { if (isStale) { - generate(); + updateCache(); } let id = ''; do { @@ -184,7 +184,7 @@ export function getUniqueId(): string { /** Returns index of an event with a given id */ export function getIndexOf(eventId: EntryId) { if (isStale) { - generate(); + updateCache(); } return currentRundown.order.indexOf(eventId); } @@ -192,7 +192,7 @@ export function getIndexOf(eventId: EntryId) { /** Returns id of an event at a given index */ export function getIdOf(index: number) { if (isStale) { - generate(); + updateCache(); } return currentRundown.order.at(index); } @@ -213,7 +213,7 @@ type RundownCache = { */ export function get(): Readonly { if (isStale) { - generate(); + updateCache(); } return { id: currentRundown.id, @@ -232,7 +232,7 @@ export function get(): Readonly { */ export function getMetadata(): Readonly { if (isStale) { - generate(); + updateCache(); } return { @@ -252,7 +252,7 @@ export type RundownOrder = { */ export function getEventOrder(): Readonly { if (isStale) { - generate(); + updateCache(); } return { order: currentRundown.order, @@ -482,7 +482,7 @@ function invalidateIfUsed(label: CustomFieldLabel) { // ... and schedule a cache update // schedule a non priority cache update setImmediate(async () => { - generate(); + updateCache(); await getDataProvider().setRundown(currentRundownId, currentRundown); }); } diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index 0d4ce8a871..92829bb336 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -149,10 +149,18 @@ export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): /** * Gets the first rundown in the project - * We ensure that the projects always have a rundown + * We know that the project has at least one rundown */ export function getFirstRundown(rundowns: ProjectRundowns): Rundown { const firstKey = Object.keys(rundowns)[0]; + + // eslint-disable-next-line no-unused-labels -- dev code path + DEV: { + if (!firstKey) { + throw new Error('rundownUtils.getFirstRundown() No rundowns found'); + } + } + return rundowns[firstKey]; } diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 5ba8eb270a..ca7c29b6a9 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -146,7 +146,6 @@ describe('test aliases import', () => { const testData = { rundown: [], settings: { - app: 'ontime', version: '2.0.0', }, urlPresets: [ @@ -171,7 +170,6 @@ describe('test views import', () => { const testData = { rundown: [], settings: { - app: 'ontime', version: '2.0.0', }, viewSettings: { @@ -205,7 +203,6 @@ describe('test views import', () => { const testData = { rundown: [], settings: { - app: 'ontime', version: '2.0.0', }, } as unknown as DatabaseModel; diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index 01a0b56f22..223d0425a5 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -229,11 +229,10 @@ describe('parseSettings()', () => { expect(() => parseSettings({})).toThrow(); }); - it('returns an a base model as long as we have the app and version', () => { - const result = parseSettings({ settings: { app: 'ontime', version: '1' } as Settings }); + it('returns an a base model as long as we have the app version', () => { + const result = parseSettings({ settings: { version: '1' } as Settings }); expect(result).toBeTypeOf('object'); expect(result).toMatchObject({ - app: 'ontime', version: expect.any(String), serverPort: 4001, editorKey: null, diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 3934fb6bcc..84b1cbd0ed 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -24,6 +24,7 @@ import { createEvent, type ErrorEmitter } from './parser.js'; /** * Parse a rundowns object along with the project custom fields + * Returns a default rundown if none exists */ export function parseRundowns( data: Partial, @@ -32,6 +33,8 @@ export function parseRundowns( // 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 { @@ -193,14 +196,14 @@ export function parseProject(data: Partial, emitError?: ErrorEmit */ export function parseSettings(data: Partial): Settings { // skip if file definition is missing - if (!data.settings || data.settings?.app !== 'ontime' || data.settings?.version == null) { - throw new Error('ERROR: unable to parse settings, missing app or version'); + // TODO: skip parsing if the version is not correct + if (!data.settings || data.settings?.version == null) { + throw new Error('ERROR: unable to parse settings, missing or incorrect version'); } console.log('Found settings, importing...'); return { - app: dbModel.settings.app, version: dbModel.settings.version, serverPort: data.settings.serverPort ?? dbModel.settings.serverPort, editorKey: data.settings.editorKey ?? null, diff --git a/packages/types/src/definitions/core/Settings.type.ts b/packages/types/src/definitions/core/Settings.type.ts index 33b9a49e83..eb423b41cc 100644 --- a/packages/types/src/definitions/core/Settings.type.ts +++ b/packages/types/src/definitions/core/Settings.type.ts @@ -1,7 +1,6 @@ import type { TimeFormat } from './TimeFormat.type.js'; export type Settings = { - app: 'ontime'; version: string; serverPort: number; editorKey: null | string; From 3f3ecce7b266db43dfb0c559f9adff528ad26ac4 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 12 Apr 2025 12:53:02 +0200 Subject: [PATCH 12/49] refactor(e2e): skip flaky test --- e2e/tests/000-upload-showfile.spec.ts | 2 +- package.json | 2 +- pnpm-lock.yaml | 26 +++++++++++++------------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index 1b00fb8e05..493313bbda 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -35,7 +35,7 @@ test('project file upload', async ({ page }) => { await expect(thirdTitle).toHaveValue('Lithuania'); }); -test('project file download', async ({ page }) => { +test.fixme('project file download', async ({ page }) => { await page.goto('http://localhost:4001/editor'); await page.getByRole('button', { name: 'toggle settings' }).click(); diff --git a/package.json b/package.json index 51bc5c72c5..d8422e8c75 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "clear-temp": "rm -rf e2e/tests/fixtures/tmp" }, "devDependencies": { - "@playwright/test": "^1.49.1", + "@playwright/test": "^1.51.1", "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5f9e688c7..791e0d0bbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: .: devDependencies: '@playwright/test': - specifier: ^1.49.1 - version: 1.49.1 + specifier: ^1.51.1 + version: 1.51.1 '@types/node': specifier: 'catalog:' version: 20.17.16 @@ -1674,8 +1674,8 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.49.1': - resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==} + '@playwright/test@1.51.1': + resolution: {integrity: sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==} engines: {node: '>=18'} hasBin: true @@ -4112,13 +4112,13 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - playwright-core@1.49.1: - resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==} + playwright-core@1.51.1: + resolution: {integrity: sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==} engines: {node: '>=18'} hasBin: true - playwright@1.49.1: - resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==} + playwright@1.51.1: + resolution: {integrity: sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==} engines: {node: '>=18'} hasBin: true @@ -6474,9 +6474,9 @@ snapshots: '@pkgr/core@0.1.1': {} - '@playwright/test@1.49.1': + '@playwright/test@1.51.1': dependencies: - playwright: 1.49.1 + playwright: 1.51.1 '@popperjs/core@2.11.8': {} @@ -9295,11 +9295,11 @@ snapshots: picomatch@2.3.1: {} - playwright-core@1.49.1: {} + playwright-core@1.51.1: {} - playwright@1.49.1: + playwright@1.51.1: dependencies: - playwright-core: 1.49.1 + playwright-core: 1.51.1 optionalDependencies: fsevents: 2.3.2 From 6c8ddeec23dbba5a0c67233e801b2808f7e92536 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 13 Apr 2025 10:59:06 +0200 Subject: [PATCH 13/49] refactor: fix delay positioning in gaps --- .../__tests__/rundownCache.test.ts | 67 +++++++++++++++++++ .../rundown-service/rundownCache.utils.ts | 11 +++ 2 files changed, 78 insertions(+) diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 55348b7593..0c644738e3 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -170,6 +170,73 @@ describe('generate()', () => { expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start }); + it('maintains delays over gaps', () => { + const rundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 500 }), + '2': makeOntimeEvent({ id: '2', timeStart: 500, timeEnd: 600, duration: 100 }), + }, + }); + + const initResult = generate(rundown, {}); + expect(initResult.entries['2']).toMatchObject({ + gap: 400, + delay: 500, + }); + expect(initResult.totalDelay).toBe(500); + expect(initResult.totalDuration).toBe(600); + }); + + it('maintains delays over gaps when there are cumulative delays', () => { + const rundown = makeRundown({ + order: ['delay1', '1', 'delay2', '2'], + entries: { + delay1: makeOntimeDelay({ id: 'delay', duration: 100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay2: makeOntimeDelay({ id: 'delay', duration: 300 }), + '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + + const initResult = generate(rundown, {}); + expect(initResult.entries['1']).toMatchObject({ + gap: 0, + delay: 100, + }); + expect(initResult.entries['2']).toMatchObject({ + gap: 200, + delay: 300, // first delay was fully absorbed + }); + expect(initResult.totalDelay).toBe(300); + expect(initResult.totalDuration).toBe(400); + }); + + it('handles negative delays on gaps', () => { + const rundown = makeRundown({ + order: ['delay1', '1', 'delay2', '2'], + entries: { + delay1: makeOntimeDelay({ id: 'delay', duration: -100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay2: makeOntimeDelay({ id: 'delay', duration: -300 }), + '2': makeOntimeEvent({ id: '2', timeStart: 300, timeEnd: 400, duration: 100 }), + }, + }); + + const initResult = generate(rundown, {}); + expect(initResult.entries['1']).toMatchObject({ + gap: 0, + delay: -100, + }); + expect(initResult.entries['2']).toMatchObject({ + gap: 200, + delay: -400, + }); + expect(initResult.totalDelay).toBe(-400); + expect(initResult.totalDuration).toBe(400); + }); + it('handles negative delays', () => { const rundown = makeRundown({ order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index ac62639113..42a34e2acf 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -144,6 +144,7 @@ export type ProcessedRundownMetadata = RundownMetadata & { order: EntryId[]; previousEvent: PlayableEvent | null; // The playableEvent from the previous iteration latestEvent: PlayableEvent | null; // The playableEvent most forwards in time processed so far + previousEntry: OntimeEntry | null; // The entry processed in the previous iteration }; export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record) { @@ -163,6 +164,7 @@ export function makeRundownMetadata(customFields: CustomFields, customFieldChang order: [], previousEvent: null, latestEvent: null, + previousEntry: null, }; function process( @@ -252,8 +254,16 @@ function processEntry( // remove eventual gaps from the accumulated delay // we only affect positive delays (time forwards) if (processedData.totalDelay > 0 && currentEntry.gap > 0) { + let correctedDelay = 0; + // we need to separate the delay that is accumulated from one that may exist after the gap + if (isOntimeDelay(processedData.previousEntry)) { + correctedDelay = processedData.previousEntry.duration; + processedData.totalDelay -= correctedDelay; + } processedData.totalDelay = Math.max(processedData.totalDelay - currentEntry.gap, 0); + processedData.totalDelay += correctedDelay; } + // current event delay is the current accumulated delay currentEntry.delay = processedData.totalDelay; @@ -275,6 +285,7 @@ function processEntry( processedData.order.push(currentEntry.id); } processedData.entries[currentEntry.id] = currentEntry; + processedData.previousEntry = currentEntry; return { processedData, processedEntry: currentEntry }; } From 0cae37d6c8512c0bed2a54d98df844bc7a37c79b Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 13 Apr 2025 20:57:54 +0200 Subject: [PATCH 14/49] chore: rename currentBlock > parent --- .../utils/__tests__/eventsManager.test.ts | 4 +-- apps/client/src/common/utils/eventsManager.ts | 2 +- apps/server/src/models/demoProject.ts | 28 ++++++++-------- apps/server/src/models/eventsDefinition.ts | 2 +- .../__tests__/rundownCache.test.ts | 14 ++++---- .../rundown-service/rundownCache.utils.ts | 2 +- .../__tests__/sheetUtils.test.ts | 12 +++---- .../src/stores/__tests__/runtimeState.test.ts | 32 +++++++++---------- apps/server/src/stores/runtimeState.ts | 2 +- apps/server/src/utils/parser.ts | 2 +- apps/server/test-db/db.json | 28 ++++++++-------- e2e/tests/fixtures/e2e-test-db.json | 28 ++++++++-------- .../src/definitions/core/OntimeEvent.type.ts | 2 +- .../src/rundown-utils/rundownUtils.test.ts | 14 ++++---- .../utils/src/rundown-utils/rundownUtils.ts | 8 ++--- 15 files changed, 90 insertions(+), 90 deletions(-) diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index 7cd2632299..8e11811083 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -15,7 +15,7 @@ describe('cloneEvent()', () => { timeEnd: 10, timerType: TimerType.CountDown, timeStrategy: TimeStrategy.LockEnd, - currentBlock: 'test', + parent: 'test', linkStart: false, countToEnd: false, endAction: EndAction.None, @@ -46,7 +46,7 @@ describe('cloneEvent()', () => { timeEnd: original.timeEnd, timerType: original.timerType, timeStrategy: original.timeStrategy, - currentBlock: 'test', + parent: 'test', countToEnd: original.countToEnd, linkStart: original.linkStart, endAction: original.endAction, diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index ad22b08201..47334b8499 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -23,7 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => { isPublic: event.isPublic, skip: event.skip, colour: event.colour, - currentBlock: event.currentBlock, + parent: event.parent, revision: 0, delay: event.delay, // the events will be collocated, so having the same metadata is a good start dayOffset: event.dayOffset, diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 28b9915718..6a42eb7909 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -56,7 +56,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -85,7 +85,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -114,7 +114,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -143,7 +143,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -172,7 +172,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -219,7 +219,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -248,7 +248,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -277,7 +277,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -306,7 +306,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -335,7 +335,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -382,7 +382,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -411,7 +411,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -440,7 +440,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, @@ -469,7 +469,7 @@ export const demoDb: DatabaseModel = { isPublic: true, skip: false, colour: '', - currentBlock: null, + parent: null, revision: 0, delay: 0, dayOffset: 0, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 9775924e00..74cee151aa 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -27,7 +27,7 @@ export const event: Omit = { timeDanger: 60000, custom: {}, // !==== RUNTIME METADATA ====! // - currentBlock: null, + parent: null, revision: 0, // calculated at runtime delay: 0, // calculated at runtime dayOffset: 0, // calculated at runtime diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 0c644738e3..2755310178 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -536,9 +536,9 @@ describe('generate() v4', () => { isFirstLinked: false, numEvents: 3, }, - '100': { type: SupportedEvent.Event, currentBlock: '1' }, - '200': { type: SupportedEvent.Event, currentBlock: '1' }, - '300': { type: SupportedEvent.Event, currentBlock: '1' }, + '100': { type: SupportedEvent.Event, parent: '1' }, + '200': { type: SupportedEvent.Event, parent: '1' }, + '300': { type: SupportedEvent.Event, parent: '1' }, }); }); @@ -567,7 +567,7 @@ describe('generate() v4', () => { expect(generatedRundown.totalDuration).toBe(1200); expect(generatedRundown.totalDelay).toBe(0); expect(generatedRundown.entries).toMatchObject({ - '0': { type: SupportedEvent.Event, currentBlock: null }, + '0': { type: SupportedEvent.Event, parent: null }, '1': { type: SupportedEvent.Block, events: ['101', '102', '103'], @@ -577,9 +577,9 @@ describe('generate() v4', () => { isFirstLinked: false, numEvents: 3, }, - '101': { currentBlock: '1', gap: 90, linkStart: false }, - '102': { currentBlock: '1' }, - '103': { currentBlock: '1' }, + '101': { parent: '1', gap: 90, linkStart: false }, + '102': { parent: '1' }, + '103': { parent: '1' }, '2': { type: SupportedEvent.Block, events: ['201', '202', '203'], diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 42a34e2acf..6397edf5b5 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -223,7 +223,7 @@ function processEntry( currentEntry.dayOffset = processedData.totalDays; currentEntry.delay = 0; // this means we dont calculate delays or gaps for skipped events currentEntry.gap = 0; // this means we dont calculate delays or gaps for skipped events - currentEntry.currentBlock = childOfBlock; + currentEntry.parent = childOfBlock; // update rundown metadata, it only concerns playable events if (isPlayableEvent(currentEntry)) { diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 4a650c9089..46c6569d3c 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -39,7 +39,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, - currentBlock: null, + parent: null, revision: 0, id: '1358', timeWarning: 0, @@ -85,7 +85,7 @@ describe('cellRequestFromEvent()', () => { isPublic: false, skip: false, colour: 'red', - currentBlock: null, + parent: null, revision: 0, delay: 0, gap: 0, @@ -136,7 +136,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - currentBlock: null, + parent: null, revision: 0, delay: 0, gap: 0, @@ -189,7 +189,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, - currentBlock: null, + parent: null, revision: 0, id: '1358', timeWarning: 0, @@ -222,7 +222,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - currentBlock: null, + parent: null, revision: 0, delay: 0, gap: 0, @@ -259,7 +259,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', - currentBlock: null, + parent: null, revision: 0, delay: 0, gap: 0, diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index 1c056cd53e..d097596ca4 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -28,7 +28,7 @@ const mockEvent = { timeEnd: 1000, duration: 1000, skip: false, - currentBlock: null, + parent: null, } as PlayableEvent; const mockState = { @@ -166,8 +166,8 @@ describe('mutation on runtimeState', () => { // do this before the test so that it is applied const entries = { - event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, currentBlock: null }, - event2: { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500, currentBlock: null }, + event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, parent: null }, + event2: { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500, parent: null }, }; const rundown = makeRundown({ entries, order: ['event1', 'event2'] }); @@ -346,11 +346,11 @@ describe('loadBlock', () => { test('from no-block to a block will clear startedAt', () => { const rundown = makeRundown({ entries: { - 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 0: makeOntimeEvent({ id: '0', parent: null }), 1: makeOntimeBlock({ id: '1', events: ['11'] }), - 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 11: makeOntimeEvent({ id: '11', parent: '1' }), 2: makeOntimeBlock({ id: '2', events: [] }), - 3: makeOntimeEvent({ id: '3', currentBlock: null }), + 3: makeOntimeEvent({ id: '3', parent: null }), }, order: ['0', '1', '2', '3'], }); @@ -374,11 +374,11 @@ describe('loadBlock', () => { test('from block to a different block will clear startedAt', () => { const rundown = makeRundown({ entries: { - 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 0: makeOntimeEvent({ id: '0', parent: null }), 1: makeOntimeBlock({ id: '1', events: ['11'] }), - 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 11: makeOntimeEvent({ id: '11', parent: '1' }), 2: makeOntimeBlock({ id: '2', events: ['22'] }), - 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + 22: makeOntimeEvent({ id: '22', parent: '2' }), }, order: ['0', '1', '2'], }); @@ -402,11 +402,11 @@ describe('loadBlock', () => { test('from block to a no-block will clear startedAt', () => { const rundown = makeRundown({ entries: { - 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 0: makeOntimeEvent({ id: '0', parent: null }), 1: makeOntimeBlock({ id: '1', events: ['11'] }), - 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 11: makeOntimeEvent({ id: '11', parent: '1' }), 2: makeOntimeBlock({ id: '2', events: ['22'] }), - 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + 22: makeOntimeEvent({ id: '22', parent: '2' }), }, order: ['0', '1', '2'], }); @@ -431,8 +431,8 @@ describe('loadBlock', () => { const rundown = makeRundown({ entries: { 0: makeOntimeBlock({ id: '0', events: ['1', '2'] }), - 1: makeOntimeEvent({ id: '1', currentBlock: '0' }), - 2: makeOntimeEvent({ id: '2', currentBlock: '0' }), + 1: makeOntimeEvent({ id: '1', parent: '0' }), + 2: makeOntimeEvent({ id: '2', parent: '0' }), }, order: ['0'], }); @@ -456,8 +456,8 @@ describe('loadBlock', () => { test('from no-block to no-block will keep startedAt', () => { const rundown = makeRundown({ entries: { - 0: makeOntimeEvent({ id: '0', currentBlock: null }), - 1: makeOntimeEvent({ id: '1', currentBlock: null }), + 0: makeOntimeEvent({ id: '0', parent: null }), + 1: makeOntimeEvent({ id: '1', parent: null }), }, order: ['0', '1'], }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 54ab4d17c2..0afca1b3e7 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -733,7 +733,7 @@ export function loadBlock(rundown: Rundown, state = runtimeState) { return; } - const currentBlockId = state.eventNow.currentBlock; + const currentBlockId = state.eventNow.parent; // update time only if the block has changed if (state.currentBlock.block?.id != currentBlockId) { diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 753904bf40..d752162dcc 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -424,7 +424,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial { duration: 1, delay: 1, revision: 3, - currentBlock: null, + parent: null, } as OntimeEvent; const eventB = { id: '2', @@ -213,7 +213,7 @@ describe('swapEventData', () => { duration: 2, delay: 2, revision: 7, - currentBlock: 'testing', + parent: 'testing', } as OntimeEvent; const [newA, newB] = swapEventData(eventA, eventB); @@ -226,7 +226,7 @@ describe('swapEventData', () => { duration: 1, delay: 1, revision: 3, - currentBlock: null, + parent: null, }); expect(newB).toMatchObject({ id: '2', @@ -236,7 +236,7 @@ describe('swapEventData', () => { duration: 2, delay: 2, revision: 7, - currentBlock: 'testing', + parent: 'testing', }); }); }); @@ -341,9 +341,9 @@ describe('getLastEvent', () => { entries: { 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, block: { id: 'block', type: SupportedEvent.Block, events: ['21', '22', '23'] } as OntimeBlock, - 21: { id: '21', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, - 22: { id: '22', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, - 23: { id: '23', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + 21: { id: '21', type: SupportedEvent.Event, parent: 'block' } as OntimeEvent, + 22: { id: '22', type: SupportedEvent.Event, parent: 'block' } as OntimeEvent, + 23: { id: '23', type: SupportedEvent.Event, parent: 'block' } as OntimeEvent, }, order: ['1', 'block'], }; diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index a3937cb527..571ce793cd 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -278,7 +278,7 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: timeEnd: eventA.timeEnd, duration: eventA.duration, linkStart: eventA.linkStart, - currentBlock: eventA.currentBlock, + parent: eventA.parent, // keep schedule metadata delay: eventA.delay, gap: eventA.gap, @@ -296,7 +296,7 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: timeEnd: eventB.timeEnd, duration: eventB.duration, linkStart: eventB.linkStart, - currentBlock: eventB.currentBlock, + parent: eventB.parent, // keep schedule metadata delay: eventB.delay, gap: eventB.gap, @@ -365,8 +365,8 @@ export function getPreviousBlock(rundown: Pick, cu const currentEvent = rundown.entries[currentId]; // check if event is inside a block - if (isOntimeEvent(currentEvent) && currentEvent.currentBlock) { - return rundown.entries[currentEvent.currentBlock] as OntimeBlock; + if (isOntimeEvent(currentEvent) && currentEvent.parent) { + return rundown.entries[currentEvent.parent] as OntimeBlock; } let foundCurrentEvent = false; From 57bd317dcf6ada837d5bed96ca0d031213f00019 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sun, 13 Apr 2025 20:46:47 +0200 Subject: [PATCH 15/49] chore: improve convention entry <> event --- apps/client/src/common/api/rundown.ts | 27 +-- apps/client/src/common/api/utils.ts | 4 +- .../input/delay-input/DelayInput.tsx | 6 +- .../{useEventAction.ts => useEntryAction.ts} | 178 +++++++++--------- .../operator/edit-modal/EditModal.tsx | 6 +- apps/client/src/features/rundown/Rundown.tsx | 24 +-- .../src/features/rundown/RundownEntry.tsx | 24 +-- .../rundown/common/EditableBlockTitle.tsx | 8 +- .../rundown/delay-block/DelayBlock.tsx | 6 +- .../composite/EventBlockPlayback.tsx | 6 +- .../rundown/event-editor/EventEditor.tsx | 10 +- .../composite/EventEditorTimes.tsx | 12 +- .../rundown/quick-add-block/QuickAddBlock.tsx | 12 +- .../rundown/rundown-header/RundownMenu.tsx | 8 +- .../rundown/time-input-flow/TimeInputFlow.tsx | 8 +- .../src/features/rundown/useEventSelection.ts | 10 +- .../cuesheet/cuesheet-table/CuesheetTable.tsx | 8 +- .../CuesheetTableMenuActions.tsx | 18 +- .../api-data/rundown/rundown.controller.ts | 12 +- .../src/api-integration/integration.utils.ts | 4 +- .../rundown-service/RundownService.ts | 8 +- .../services/rundown-service/rundownUtils.ts | 12 +- .../runtime-service/RuntimeService.ts | 10 +- 23 files changed, 211 insertions(+), 210 deletions(-) rename apps/client/src/common/hooks/{useEventAction.ts => useEntryAction.ts} (80%) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 99fb044d4d..aa7ff1c001 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,5 +1,6 @@ import axios, { AxiosResponse } from 'axios'; import { + EntryId, MessageResponse, OntimeEntry, OntimeEvent, @@ -29,16 +30,16 @@ export async function fetchCurrentRundown(): Promise { } /** - * HTTP request to post new event + * HTTP request to post new entry */ -export async function requestPostEvent(data: TransientEventPayload): Promise> { +export async function postAddEntry(data: TransientEventPayload): Promise> { return axios.post(rundownPath, data); } /** - * HTTP request to put new event + * HTTP request to edit an entry */ -export async function requestPutEvent(data: Partial): Promise> { +export async function putEditEntry(data: Partial): Promise> { return axios.put(rundownPath, data); } @@ -48,9 +49,9 @@ type BatchEditEntry = { }; /** - * HTTP request to put multiple events + * HTTP request to edit multiple events */ -export async function requestBatchPutEvents(data: BatchEditEntry): Promise> { +export async function putBatchEditEvents(data: BatchEditEntry): Promise> { return axios.put(`${rundownPath}/batch`, data); } @@ -61,9 +62,9 @@ export type ReorderEntry = { }; /** - * HTTP request to reorder events + * HTTP request to reorder an entry */ -export async function requestReorderEvent(data: ReorderEntry): Promise> { +export async function patchReorderEntry(data: ReorderEntry): Promise> { return axios.patch(`${rundownPath}/reorder`, data); } @@ -82,15 +83,15 @@ export async function requestEventSwap(data: SwapEntry): Promise> { - return axios.patch(`${rundownPath}/applydelay/${eventId}`); +export async function requestApplyDelay(delayId: string): Promise> { + return axios.patch(`${rundownPath}/applydelay/${delayId}`); } /** - * HTTP request to delete given event + * HTTP request to delete entries */ -export async function requestDelete(eventIds: string[]): Promise> { - return axios.delete(rundownPath, { data: { ids: eventIds } }); +export async function deleteEntries(entryIds: EntryId[]): Promise> { + return axios.delete(rundownPath, { data: { ids: entryIds } }); } /** diff --git a/apps/client/src/common/api/utils.ts b/apps/client/src/common/api/utils.ts index 9f2f35ce2c..b49fc335c4 100644 --- a/apps/client/src/common/api/utils.ts +++ b/apps/client/src/common/api/utils.ts @@ -7,7 +7,7 @@ import { addLog } from '../stores/logger'; import { nowInMillis } from '../utils/time'; /** - * Utility unrwap a potential axios error + * Utility unwrap a potential axios error * @param error * @returns */ @@ -34,7 +34,7 @@ export function maybeAxiosError(error: unknown) { } /** - * Utility unrwaps a potential axios error and sends to logger + * Utility unwraps a potential axios error and sends to logger * @param prepend * @param error */ diff --git a/apps/client/src/common/components/input/delay-input/DelayInput.tsx b/apps/client/src/common/components/input/delay-input/DelayInput.tsx index 1187eb6c8f..bf4e228516 100644 --- a/apps/client/src/common/components/input/delay-input/DelayInput.tsx +++ b/apps/client/src/common/components/input/delay-input/DelayInput.tsx @@ -2,7 +2,7 @@ import { KeyboardEvent, useEffect, useRef, useState } from 'react'; import { Input, Radio, RadioGroup } from '@chakra-ui/react'; import { millisToString, parseUserTime } from 'ontime-utils'; -import { useEventAction } from '../../../hooks/useEventAction'; +import { useEntryActions } from '../../../hooks/useEntryAction'; import style from './DelayInput.module.scss'; @@ -13,7 +13,7 @@ interface DelayInputProps { export default function DelayInput(props: DelayInputProps) { const { eventId, duration } = props; - const { updateEvent } = useEventAction(); + const { updateEntry } = useEntryActions(); const [value, setValue] = useState(''); const inputRef = useRef(null); @@ -54,7 +54,7 @@ export default function DelayInput(props: DelayInputProps) { }; const submitChange = (value: number) => { - updateEvent({ + updateEntry({ id: eventId, duration: value, }); diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEntryAction.ts similarity index 80% rename from apps/client/src/common/hooks/useEventAction.ts rename to apps/client/src/common/hooks/useEntryAction.ts index 41d9fc1b64..4600c36186 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -1,10 +1,9 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { + EntryId, isOntimeEvent, MaybeString, - OntimeBlock, - OntimeDelay, OntimeEntry, OntimeEvent, Rundown, @@ -16,15 +15,15 @@ import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData import { RUNDOWN } from '../api/constants'; import { + deleteEntries, + patchReorderEntry, + postAddEntry, + putBatchEditEvents, + putEditEntry, ReorderEntry, requestApplyDelay, - requestBatchPutEvents, - requestDelete, requestDeleteAll, requestEventSwap, - requestPostEvent, - requestPutEvent, - requestReorderEvent, SwapEntry, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; @@ -41,9 +40,9 @@ export type EventOptions = Partial<{ }>; /** - * @description Set of utilities for events //TODO: should this be called useEntryAction and so on + * Gather utilities for actions on entries */ -export const useEventAction = () => { +export const useEntryActions = () => { const queryClient = useQueryClient(); const { defaultPublic, @@ -56,8 +55,8 @@ export const useEventAction = () => { defaultEndAction, } = useEditorSettings(); - const getEventById = useCallback( - (eventId: string) => { + const getEntryById = useCallback( + (eventId: string): OntimeEntry | undefined => { const cachedRundown = queryClient.getQueryData(RUNDOWN); if (!cachedRundown?.entries) { return; @@ -68,11 +67,11 @@ export const useEventAction = () => { ); /** - * Calls mutation to add new event + * Calls mutation to add new entry * @private */ - const _addEventMutation = useMutation({ - mutationFn: requestPostEvent, + const _addEntryMutation = useMutation({ + mutationFn: postAddEntry, onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, @@ -80,14 +79,14 @@ export const useEventAction = () => { }); /** - * Adds an event to rundown + * Adds an entry to rundown */ - const addEvent = useCallback( - async (event: Partial, options?: EventOptions) => { - const newEvent: TransientEventPayload = { ...event }; + const addEntry = useCallback( + async (entry: Partial, options?: EventOptions) => { + const newEntry: TransientEventPayload = { ...entry }; // ************* CHECK OPTIONS specific to events - if (isOntimeEvent(newEvent)) { + if (isOntimeEvent(newEntry)) { // merge creation time options with event settings const applicationOptions = { after: options?.after, @@ -102,57 +101,57 @@ export const useEventAction = () => { const rundownData = queryClient.getQueryData(RUNDOWN)!; const previousEvent = rundownData.entries[applicationOptions.lastEventId]; if (isOntimeEvent(previousEvent)) { - newEvent.timeStart = previousEvent.timeEnd; + newEntry.timeStart = previousEvent.timeEnd; } } // Override event with options from editor settings - newEvent.linkStart = applicationOptions.linkPrevious; - newEvent.isPublic = applicationOptions.defaultPublic; + newEntry.linkStart = applicationOptions.linkPrevious; + newEntry.isPublic = applicationOptions.defaultPublic; - if (newEvent.duration === undefined && newEvent.timeEnd === undefined) { - newEvent.duration = parseUserTime(defaultDuration); + if (newEntry.duration === undefined && newEntry.timeEnd === undefined) { + newEntry.duration = parseUserTime(defaultDuration); } - if (newEvent.timeDanger === undefined) { - newEvent.timeDanger = parseUserTime(defaultDangerTime); + if (newEntry.timeDanger === undefined) { + newEntry.timeDanger = parseUserTime(defaultDangerTime); } - if (newEvent.timeWarning === undefined) { - newEvent.timeWarning = parseUserTime(defaultWarnTime); + if (newEntry.timeWarning === undefined) { + newEntry.timeWarning = parseUserTime(defaultWarnTime); } - if (newEvent.timerType === undefined) { - newEvent.timerType = defaultTimerType; + if (newEntry.timerType === undefined) { + newEntry.timerType = defaultTimerType; } - if (newEvent.endAction === undefined) { - newEvent.endAction = defaultEndAction; + if (newEntry.endAction === undefined) { + newEntry.endAction = defaultEndAction; } - if (newEvent.timeStrategy === undefined) { - newEvent.timeStrategy = defaultTimeStrategy; + if (newEntry.timeStrategy === undefined) { + newEntry.timeStrategy = defaultTimeStrategy; } } // handle adding options that concern all event type if (options?.after) { // @ts-expect-error -- not sure how to type this, is a transient property - newEvent.after = options.after; + newEntry.after = options.after; } if (options?.before) { // @ts-expect-error -- not sure how to type this, is a transient property - newEvent.before = options.before; + newEntry.before = options.before; } try { - await _addEventMutation.mutateAsync(newEvent as TransientEventPayload); + await _addEntryMutation.mutateAsync(newEntry as TransientEventPayload); } catch (error) { logAxiosError('Failed adding event', error); } }, [ - _addEventMutation, + _addEntryMutation, defaultDangerTime, defaultDuration, defaultEndAction, @@ -166,11 +165,11 @@ export const useEventAction = () => { ); /** - * Calls mutation to update existing event + * Calls mutation to update existing entry * @private */ - const _updateEventMutation = useMutation({ - mutationFn: requestPutEvent, + const _updateEntryMutation = useMutation({ + mutationFn: putEditEntry, // we optimistically update here onMutate: async (newEvent) => { // cancel ongoing queries @@ -210,35 +209,35 @@ export const useEventAction = () => { }); /** - * Updates existing event + * Updates existing entry */ - const updateEvent = useCallback( + const updateEntry = useCallback( async (event: Partial) => { try { - await _updateEventMutation.mutateAsync(event); + await _updateEntryMutation.mutateAsync(event); } catch (error) { logAxiosError('Error updating event', error); } }, - [_updateEventMutation], + [_updateEntryMutation], ); const updateCustomField = useCallback( - async (eventId: string, field: string, value: string) => { - updateEvent({ id: eventId, custom: { [field]: value } }); + async (entryId: EntryId, field: string, value: string) => { + updateEntry({ id: entryId, custom: { [field]: value } }); }, - [updateEvent], + [updateEntry], ); /** * Updates time of existing event - * @param eventId {string} - id of the event + * @param eventId {EntryId} - id of the event * @param field {TimeField} - field to update * @param value {string} - new value string to be parsed * @param lockOnUpdate {boolean} - whether we will apply the lock / release on update */ const updateTimer = useCallback( - async (eventId: string, field: TimeField, value: string, lockOnUpdate?: boolean) => { + async (eventId: EntryId, field: TimeField, value: string, lockOnUpdate?: boolean) => { // an empty value with no lock has no domain validity if (!lockOnUpdate && value === '') { return; @@ -268,7 +267,7 @@ export const useEventAction = () => { } try { - await _updateEventMutation.mutateAsync(newEvent); + await _updateEntryMutation.mutateAsync(newEvent); } catch (error) { logAxiosError('Error updating event', error); } @@ -320,7 +319,7 @@ export const useEventAction = () => { return previousEnd; } }, - [_updateEventMutation, queryClient], + [_updateEntryMutation, queryClient], ); /** @@ -328,7 +327,7 @@ export const useEventAction = () => { * @private */ const _batchUpdateEventsMutation = useMutation({ - mutationFn: requestBatchPutEvents, + mutationFn: putBatchEditEvents, onMutate: async ({ ids, data }) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); @@ -360,6 +359,7 @@ export const useEventAction = () => { revision: -1, }); } + // Return a context with the previous rundown return { previousRundown }; }, @@ -384,13 +384,13 @@ export const useEventAction = () => { ); /** - * Calls mutation to delete an event + * Calls mutation to delete an entry * @private */ - const _deleteEventMutation = useMutation({ - mutationFn: requestDelete, + const _deleteEntryMutation = useMutation({ + mutationFn: deleteEntries, // we optimistically update here - onMutate: async (eventIds: string[]) => { + onMutate: async (entryIds: EntryId[]) => { // cancel ongoing queries await queryClient.cancelQueries({ queryKey: RUNDOWN }); @@ -399,9 +399,9 @@ export const useEventAction = () => { if (previousData) { // optimistically update object - const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); + const newOrder = previousData.order.filter((id) => !entryIds.includes(id)); const newRundown = { ...previousData.entries }; - for (const eventId of eventIds) { + for (const eventId of entryIds) { delete newRundown[eventId]; } @@ -419,7 +419,7 @@ export const useEventAction = () => { }, // Mutation fails, rollback undoes optimist update - onError: (_error, _eventId, context) => { + onError: (_error, _entryIds, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful @@ -431,24 +431,24 @@ export const useEventAction = () => { }); /** - * Deletes an event form the list + * Deletes an event entry from the rundown */ - const deleteEvent = useCallback( - async (eventIds: string[]) => { + const deleteEntry = useCallback( + async (entryIds: EntryId[]) => { try { - await _deleteEventMutation.mutateAsync(eventIds); + await _deleteEntryMutation.mutateAsync(entryIds); } catch (error) { logAxiosError('Error deleting event', error); } }, - [_deleteEventMutation], + [_deleteEntryMutation], ); /** * Calls mutation to delete all events * @private */ - const _deleteAllEventsMutation = useMutation({ + const _deleteAllEntriesMutation = useMutation({ mutationFn: requestDeleteAll, // we optimistically update here onMutate: async () => { @@ -471,8 +471,8 @@ export const useEventAction = () => { return { previousData }; }, - // Mutation fails, rollback undos optimist update - onError: (_error, _eventId, context) => { + // Mutation fails, rollback optimist update + onError: (_error, _, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful @@ -484,15 +484,15 @@ export const useEventAction = () => { }); /** - * Deletes all events from list + * Deletes all entries in the rundown */ - const deleteAllEvents = useCallback(async () => { + const deleteAllEntries = useCallback(async () => { try { - await _deleteAllEventsMutation.mutateAsync(); + await _deleteAllEntriesMutation.mutateAsync(); } catch (error) { logAxiosError('Error deleting events', error); } - }, [_deleteAllEventsMutation]); + }, [_deleteAllEntriesMutation]); /** * Calls mutation to apply a delay @@ -508,7 +508,7 @@ export const useEventAction = () => { }); /** - * Applies a given delay block + * Applies a given delay */ const applyDelay = useCallback( async (delayEventId: string) => { @@ -522,11 +522,11 @@ export const useEventAction = () => { ); /** - * Calls mutation to reorder an event + * Calls mutation to reorder an entry * @private */ - const _reorderEventMutation = useMutation({ - mutationFn: requestReorderEvent, + const _reorderEntryMutation = useMutation({ + mutationFn: patchReorderEntry, // we optimistically update here onMutate: async (data) => { // cancel ongoing queries @@ -552,7 +552,7 @@ export const useEventAction = () => { }, // Mutation fails, rollback undoes optimist update - onError: (_error, _eventId, context) => { + onError: (_error, _data, context) => { queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful @@ -564,22 +564,22 @@ export const useEventAction = () => { }); /** - * Reorders a given event + * Reorders a given entry */ - const reorderEvent = useCallback( - async (eventId: string, from: number, to: number) => { + const reorderEntry = useCallback( + async (entryId: string, from: number, to: number) => { try { const reorderObject: ReorderEntry = { - eventId, + eventId: entryId, from, to, }; - await _reorderEventMutation.mutateAsync(reorderObject); + await _reorderEntryMutation.mutateAsync(reorderObject); } catch (error) { logAxiosError('Error re-ordering event', error); } }, - [_reorderEventMutation], + [_reorderEntryMutation], ); /** @@ -649,15 +649,15 @@ export const useEventAction = () => { ); return { - addEvent, + addEntry, applyDelay, batchUpdateEvents, - deleteEvent, - deleteAllEvents, - getEventById, - reorderEvent, + deleteEntry, + deleteAllEntries, + getEntryById, + reorderEntry, swapEvents, - updateEvent, + updateEntry, updateTimer, updateCustomField, }; diff --git a/apps/client/src/features/operator/edit-modal/EditModal.tsx b/apps/client/src/features/operator/edit-modal/EditModal.tsx index a059ee81ef..49b45d911f 100644 --- a/apps/client/src/features/operator/edit-modal/EditModal.tsx +++ b/apps/client/src/features/operator/edit-modal/EditModal.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { Button, Textarea } from '@chakra-ui/react'; import { OntimeEvent } from 'ontime-types'; -import { useEventAction } from '../../../common/hooks/useEventAction'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; import type { EditEvent } from '../Operator'; import style from './EditModal.module.scss'; @@ -15,7 +15,7 @@ interface EditModalProps { export default function EditModal(props: EditModalProps) { const { event, onClose } = props; - const { updateEvent } = useEventAction(); + const { updateEntry } = useEntryActions(); const [loading, setLoading] = useState(false); const inputRef = useRef(new Array()); @@ -36,7 +36,7 @@ export default function EditModal(props: EditModalProps) { }); if (patchObject.custom) { - await updateEvent(patchObject); + await updateEntry(patchObject); } setLoading(false); diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 7d65c04cf6..3104d66f5f 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -21,7 +21,7 @@ import { reorderArray, } from 'ontime-utils'; -import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; +import { type EventOptions, useEntryActions } from '../../common/hooks/useEntryAction'; import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; @@ -48,7 +48,7 @@ export default function Rundown({ data }: RundownProps) { const [statefulEntries, setStatefulEntries] = useState(order); const featureData = useRundownEditor(); - const { addEvent, reorderEvent, deleteEvent } = useEventAction(); + const { addEntry, reorderEntry, deleteEntry } = useEntryActions(); const { entryCopyId, setEntryCopyId } = useEntryCopy(); @@ -67,12 +67,12 @@ export default function Rundown({ data }: RundownProps) { (cursor: string | null) => { if (!cursor) return; const { entry, index } = getPreviousNormal(entries, order, cursor); - deleteEvent([cursor]); + deleteEntry([cursor]); if (entry && index !== null) { setSelectedEvents({ id: entry.id, selectMode: 'click', index }); } }, - [entries, order, deleteEvent, setSelectedEvents], + [entries, order, deleteEntry, setSelectedEvents], ); const insertCopyAtId = useCallback( @@ -86,10 +86,10 @@ export default function Rundown({ data }: RundownProps) { if (cloneEntry?.type === SupportedEvent.Event) { //if we don't have a cursor add the new event on top const newEvent = cloneEvent(cloneEntry); - addEvent(newEvent, { after: adjustedCursor ?? undefined }); + addEntry(newEvent, { after: adjustedCursor ?? undefined }); } }, - [addEvent, order, entries], + [addEntry, order, entries], ); const insertAtId = useCallback( @@ -109,12 +109,12 @@ export default function Rundown({ data }: RundownProps) { if (!above && id) { options.lastEventId = id; } - addEvent(newEvent, options); + addEntry(newEvent, options); } else { - addEvent({ type }, options); + addEntry({ type }, options); } }, - [addEvent], + [addEntry], ); const selectBlock = useCallback( @@ -187,10 +187,10 @@ export default function Rundown({ data }: RundownProps) { if (index !== null) { const offsetIndex = direction === 'up' ? index + 1 : index - 1; - reorderEvent(cursor, offsetIndex, index); + reorderEntry(cursor, offsetIndex, index); } }, - [order, reorderEvent, entries], + [order, reorderEntry, entries], ); // shortcuts @@ -254,7 +254,7 @@ export default function Rundown({ data }: RundownProps) { setStatefulEntries((currentEntries) => { return reorderArray(currentEntries, fromIndex, toIndex); }); - reorderEvent(String(active.id), fromIndex, toIndex); + reorderEntry(String(active.id), fromIndex, toIndex); } } }; diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 2dcf49001f..227ef37598 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -9,7 +9,7 @@ import { SupportedEvent, } from 'ontime-types'; -import { useEventAction } from '../../common/hooks/useEventAction'; +import { useEntryActions } from '../../common/hooks/useEntryAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import { useEmitLog } from '../../common/stores/logger'; import { cloneEvent } from '../../common/utils/eventsManager'; @@ -66,7 +66,7 @@ export default function RundownEntry(props: RundownEntryProps) { isLinkedToLoaded, } = props; const { emitError } = useEmitLog(); - const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction(); + const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const removeOpenEvent = useCallback(() => { @@ -91,26 +91,26 @@ export default function RundownEntry(props: RundownEntryProps) { after: data.id, lastEventId: previousEventId, }; - return addEvent(newEvent, options); + return addEntry(newEvent, options); } case 'event-before': { const newEvent = { type: SupportedEvent.Event }; const options = { after: previousEntryId, }; - return addEvent(newEvent, options); + return addEntry(newEvent, options); } case 'delay': { - return addEvent({ type: SupportedEvent.Delay }, { after: data.id }); + return addEntry({ type: SupportedEvent.Delay }, { after: data.id }); } case 'delay-before': { - return addEvent({ type: SupportedEvent.Delay }, { after: previousEntryId }); + return addEntry({ type: SupportedEvent.Delay }, { after: previousEntryId }); } case 'block': { - return addEvent({ type: SupportedEvent.Block }, { after: data.id }); + return addEntry({ type: SupportedEvent.Block }, { after: data.id }); } case 'block-before': { - return addEvent({ type: SupportedEvent.Block }, { after: previousEntryId }); + return addEntry({ type: SupportedEvent.Block }, { after: previousEntryId }); } case 'swap': { const { value } = payload as FieldValue; @@ -119,14 +119,14 @@ export default function RundownEntry(props: RundownEntryProps) { case 'delete': { if (selectedEvents.size > 1) { clearMultiSelection(); - return deleteEvent(Array.from(selectedEvents)); + return deleteEntry(Array.from(selectedEvents)); } removeOpenEvent(); - return deleteEvent([data.id]); + return deleteEntry([data.id]); } case 'clone': { const newEvent = cloneEvent(data as OntimeEvent); - addEvent(newEvent, { after: data.id }); + addEntry(newEvent, { after: data.id }); break; } case 'update': { @@ -147,7 +147,7 @@ export default function RundownEntry(props: RundownEntryProps) { if (field in data) { // @ts-expect-error -- not sure how to type this newData[field] = value; - return updateEvent(newData); + return updateEntry(newData); } return emitError(`Unknown field: ${field}`); 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.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index 6a8adee974..5b15fbcaed 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,7 +18,7 @@ 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 { @@ -48,7 +48,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/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/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index 007dca5b23..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'; @@ -23,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'); @@ -31,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/composite/EventEditorTimes.tsx b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx index 36c18b6980..ce55afa42d 100644 --- a/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx +++ b/apps/client/src/features/rundown/event-editor/composite/EventEditorTimes.tsx @@ -5,7 +5,7 @@ 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'; @@ -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; } }; 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 42a675f5b5..76edd93f4f 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -3,7 +3,7 @@ import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; import { MaybeString, SupportedEvent } from 'ontime-types'; -import { useEventAction } from '../../../common/hooks/useEventAction'; +import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEmitLog } from '../../../common/stores/logger'; import style from './QuickAddBlock.module.scss'; @@ -17,7 +17,7 @@ export default memo(QuickAddBlock); function QuickAddBlock(props: QuickAddBlockProps) { const { previousEventId, showBlocks } = props; - const { addEvent } = useEventAction(); + const { addEntry } = useEntryActions(); const { emitError } = useEmitLog(); const doLinkPrevious = useRef(null); @@ -37,7 +37,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { lastEventId: previousEventId, linkPrevious, }; - addEvent(newEvent, options); + addEntry(newEvent, options); break; } case 'delay': { @@ -45,7 +45,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { lastEventId: previousEventId, after: previousEventId, }; - addEvent({ type: SupportedEvent.Delay }, options); + addEntry({ type: SupportedEvent.Delay }, options); break; } case 'block': { @@ -53,7 +53,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { lastEventId: previousEventId, after: previousEventId, }; - addEvent({ type: SupportedEvent.Block }, options); + addEntry({ type: SupportedEvent.Block }, options); break; } default: { @@ -62,7 +62,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { } } }, - [previousEventId, addEvent, emitError], + [previousEventId, addEntry, emitError], ); return ( 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/time-input-flow/TimeInputFlow.tsx b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx index 28680965bd..993af77418 100644 --- a/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx +++ b/apps/client/src/features/rundown/time-input-flow/TimeInputFlow.tsx @@ -5,7 +5,7 @@ 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'; @@ -26,7 +26,7 @@ interface EventBlockTimerProps { 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 }); + updateEntry({ id: eventId, linkStart: doLink }); }; const warnings = []; diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index c464756ce5..697e0750e7 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,5 +1,5 @@ import { MouseEvent } from 'react'; -import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types'; +import { EntryId, isOntimeEvent, MaybeNumber, MaybeString, Rundown } from 'ontime-types'; import { create } from 'zustand'; import { RUNDOWN } from '../../common/api/constants'; @@ -66,11 +66,11 @@ export const useEventSelection = create()((set, get) => ({ if (!rundownData) return; // get list of rundown with only ontime events - const events: OntimeEvent[] = []; - rundownData.order.forEach((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/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index a3d3c59d60..a2f636f9ec 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -3,7 +3,7 @@ import { useTableNav } from '@table-nav/react'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; 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'; @@ -24,7 +24,7 @@ interface CuesheetTableProps { 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-menu/CuesheetTableMenuActions.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-menu/CuesheetTableMenuActions.tsx index f88597829b..9232bc29fc 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 @@ -2,7 +2,7 @@ import { IoAdd, IoArrowDown, IoArrowUp, IoDuplicateOutline, IoOptions, IoTrash } import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { isOntimeEvent, SupportedEvent } from 'ontime-types'; -import { useEventAction } from '../../../../common/hooks/useEventAction'; +import { useEntryActions } from '../../../../common/hooks/useEntryAction'; import { cloneEvent } from '../../../../common/utils/eventsManager'; interface CuesheetTableMenuActionsProps { @@ -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, reorderEntry, 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,10 +35,10 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions Edit ... - } onClick={() => addEvent({ type: SupportedEvent.Event }, { before: eventId })}> + } onClick={() => addEntry({ type: SupportedEvent.Event }, { before: eventId })}> Add event above - } onClick={() => addEvent({ type: SupportedEvent.Event }, { after: eventId })}> + } onClick={() => addEntry({ type: SupportedEvent.Event }, { after: eventId })}> Add event below } onClick={handleCloneEvent}> @@ -48,14 +48,14 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions } - onClick={() => reorderEvent(eventId, entryIndex, entryIndex - 1)} + onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)} > Move up - } onClick={() => reorderEvent(eventId, entryIndex, entryIndex + 1)}> + } onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}> Move down - } onClick={() => deleteEvent([eventId])}> + } onClick={() => deleteEntry([eventId])}> Delete diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index dde0501ddd..a16e7f7557 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -8,13 +8,13 @@ import { addEvent, applyDelay, batchEditEvents, - deleteAllEvents, + deleteAllEntries, deleteEvent, editEvent, - reorderEvent, + reorderEntry, swapEvents, } from '../../services/rundown-service/RundownService.js'; -import { getEventWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; +import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; export async function rundownGetAll(_req: Request, res: Response) { const rundown = getCurrentRundown(); @@ -30,7 +30,7 @@ export async function rundownGetById(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-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/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index cb6e1e34b6..a554faddf5 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -126,9 +126,9 @@ export async function deleteEvent(eventIds: string[]) { } /** - * deletes all events in database + * deletes all entries in database */ -export async function deleteAllEvents() { +export async function deleteAllEntries() { const scopedMutation = cache.mutateCache(cache.removeAll); await scopedMutation({}); @@ -182,12 +182,12 @@ export async function batchEditEvents(ids: string[], data: Partial) } /** - * reorders a given event + * reorders a given entry * @param {string} eventId - ID of event from, for sanity check * @param {number} from - index of event from * @param {number} to - index of event to */ -export async function reorderEvent(eventId: string, from: number, to: number) { +export async function reorderEntry(eventId: EntryId, from: number, to: number) { const scopedMutation = cache.mutateCache(cache.reorder); const reorderedItem = await scopedMutation({ eventId, from, to }); diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index 92829bb336..6a2ce2a560 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -60,9 +60,9 @@ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { /** * returns first event that matches a given ID */ -export function getEventWithId(eventId: string): OntimeEntry | undefined { +export function getEntryWithId(entryId: EntryId): OntimeEntry | undefined { const { entries } = getCurrentRundown(); - return entries[eventId]; + return entries[entryId]; } /** @@ -71,7 +71,7 @@ export function getEventWithId(eventId: string): OntimeEntry | undefined { export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined { const firstEventId = playableOrder.at(0); if (!firstEventId) return; - return getEventWithId(firstEventId) as PlayableEvent | undefined; + return getEntryWithId(firstEventId) as PlayableEvent | undefined; } /** @@ -84,7 +84,7 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O for (let i = currentEventIndex; i < playableEventsOrder.length; i++) { const eventId = playableEventsOrder[i]; - const event = getEventWithId(eventId) as PlayableEvent | undefined; + const event = getEntryWithId(eventId) as PlayableEvent | undefined; if (event?.cue.toLowerCase() === lowerCaseCue) { return event; } @@ -114,7 +114,7 @@ export function findPrevious(currentEventId?: string): OntimeEvent | undefined { return getFirstPlayable(playableEventsOrder); } - return getEventWithId(previousEventId) as PlayableEvent | undefined; + return getEntryWithId(previousEventId) as PlayableEvent | undefined; } /** @@ -140,7 +140,7 @@ export function findNext(currentEventId?: string): PlayableEvent | undefined { return getFirstPlayable(playableEventsOrder); } - return getEventWithId(nextEventId) as PlayableEvent | undefined; + return getEntryWithId(nextEventId) as PlayableEvent | undefined; } export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] { diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 58153b2211..3bda4acde2 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -30,7 +30,7 @@ import { findPrevious, getEventAtIndex, getNextEventWithCue, - getEventWithId, + getEntryWithId, getCurrentRundown, getTimedEvents, getRundownData, @@ -258,7 +258,7 @@ class RuntimeService { if (safeOption || eventInMemory) { if (state.eventNow !== null) { // load stuff again, but keep running if our events still exist - const eventNow = getEventWithId(state.eventNow.id); + const eventNow = getEntryWithId(state.eventNow.id); if (!isOntimeEvent(eventNow) || !isPlayableEvent(eventNow)) { // maybe the event was deleted or the skip state was changed runtimeState.stop(); @@ -323,7 +323,7 @@ class RuntimeService { */ @broadcastResult public startById(eventId: string): boolean { - const event = getEventWithId(eventId); + const event = getEntryWithId(eventId); if (!event || !isOntimeEvent(event)) { return false; } @@ -377,7 +377,7 @@ class RuntimeService { */ @broadcastResult public loadById(eventId: string): boolean { - const event = getEventWithId(eventId); + const event = getEntryWithId(eventId); if (!event || !isOntimeEvent(event)) { return false; } @@ -654,7 +654,7 @@ class RuntimeService { // the db would have to change for the event not to exist // we do not know the reason for the crash, so we check anyway - const event = getEventWithId(selectedEventId); + const event = getEntryWithId(selectedEventId); if (!isOntimeEvent(event) || !isPlayableEvent(event)) { return; } From ba1292141be3fa6368c3bbbf7b7bbfa345f38b28 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 15 Apr 2025 12:46:17 +0200 Subject: [PATCH 16/49] refactor: process events in rundown --- .../rundown/__tests__/rundown.utils.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/client/src/features/rundown/__tests__/rundown.utils.test.ts 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..4ea466519e --- /dev/null +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -0,0 +1,162 @@ +import { OntimeBlock, OntimeEvent, SupportedEvent } from 'ontime-types'; + +import { makeRundownMetadata } from '../rundown.utils'; + +describe('makeRundownMetadata()', () => { + it('processes nested rundown data', () => { + const selectedEventId = '12'; + const demoEvents = { + '1': { + id: '1', + type: SupportedEvent.Event, + parent: null, + timeStart: 0, + timeEnd: 1, + duration: 1, + dayOffset: 0, + gap: 0, + skip: false, + linkStart: false, + } as OntimeEvent, + block: { + id: 'block', + type: SupportedEvent.Block, + events: ['11, 12, 13'], + } as OntimeBlock, + '11': { + id: '11', + type: SupportedEvent.Event, + parent: 'block', + timeStart: 10, + timeEnd: 11, + duration: 1, + dayOffset: 0, + gap: 10, + skip: false, + linkStart: false, + } as OntimeEvent, + '12': { + id: '12', + type: SupportedEvent.Event, + parent: 'block', + timeStart: 11, + timeEnd: 12, + duration: 1, + dayOffset: 0, + gap: 0, + skip: false, + linkStart: true, + } as OntimeEvent, + '13': { + id: '13', + type: SupportedEvent.Event, + parent: 'block', + timeStart: 12, + timeEnd: 13, + duration: 1, + dayOffset: 0, + gap: 0, + skip: false, + linkStart: true, + } as OntimeEvent, + '2': { + id: '2', + type: SupportedEvent.Event, + parent: null, + timeStart: 20, + timeEnd: 21, + duration: 1, + dayOffset: 0, + gap: 7, + skip: false, + linkStart: false, + } as OntimeEvent, + }; + + const process = makeRundownMetadata(selectedEventId); + + 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, + isNext: false, + isNextDay: false, + totalGap: 0, + isLinkedToLoaded: false, + isLoaded: false, + }); + + expect(process(demoEvents['block'])).toMatchObject({ + previousEvent: demoEvents['1'], + latestEvent: demoEvents['1'], + previousEntryId: demoEvents['1'].id, + thisId: demoEvents['block'].id, + eventIndex: 1, + isPast: true, + isNext: false, + isNextDay: false, + totalGap: 0, + isLinkedToLoaded: false, + isLoaded: false, + }); + + expect(process(demoEvents['11'])).toMatchObject({ + previousEvent: demoEvents['1'], + latestEvent: demoEvents['11'], + previousEntryId: demoEvents['block'].id, + thisId: demoEvents['11'].id, + eventIndex: 2, + isPast: true, + isNext: false, + isNextDay: false, + totalGap: 10, + isLinkedToLoaded: false, + isLoaded: false, + }); + + expect(process(demoEvents['12'])).toMatchObject({ + previousEvent: demoEvents['11'], + latestEvent: demoEvents['12'], + previousEntryId: demoEvents['11'].id, + thisId: demoEvents['12'].id, + eventIndex: 3, + isPast: false, + isNext: false, + isNextDay: false, + totalGap: 10, + isLinkedToLoaded: false, + isLoaded: true, + }); + + expect(process(demoEvents['13'])).toMatchObject({ + previousEvent: demoEvents['12'], + latestEvent: demoEvents['13'], + previousEntryId: demoEvents['12'].id, + thisId: demoEvents['13'].id, + eventIndex: 4, + isPast: false, + isNext: false, + isNextDay: false, + totalGap: 10, + isLinkedToLoaded: true, + isLoaded: false, + }); + + expect(process(demoEvents['2'])).toMatchObject({ + previousEvent: demoEvents['13'], + latestEvent: demoEvents['2'], + previousEntryId: demoEvents['13'].id, + thisId: demoEvents['2'].id, + eventIndex: 5, + isPast: false, + isNext: false, + isNextDay: false, + totalGap: 17, + isLinkedToLoaded: false, + isLoaded: false, + }); + }); +}); From ed7f8a0aa24caa42b9a766e7a426c0c69aacc1c1 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Thu, 17 Apr 2025 09:36:22 +0200 Subject: [PATCH 17/49] refactor: implement operations on nested events --- .../client/src/common/hooks/useEntryAction.ts | 53 +++++++++-- apps/client/src/features/rundown/Rundown.tsx | 51 +++++----- .../quick-add-block/QuickAddBlock.module.scss | 1 + .../rundown/quick-add-block/QuickAddBlock.tsx | 93 +++++++++---------- .../src/features/rundown/rundown.utils.ts | 16 ++-- .../rundown-service/RundownService.ts | 61 ++++++------ .../services/rundown-service/rundownCache.ts | 84 +++++++++++++---- .../services/rundown-service/rundownUtils.ts | 36 +++++++ .../src/definitions/core/OntimeEvent.type.ts | 2 +- .../src/definitions/core/Rundown.type.ts | 1 + 10 files changed, 258 insertions(+), 140 deletions(-) diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 4600c36186..8ac0c669d3 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -4,6 +4,7 @@ import { EntryId, isOntimeEvent, MaybeString, + OntimeBlock, OntimeEntry, OntimeEvent, Rundown, @@ -11,7 +12,7 @@ import { TimeStrategy, TransientEventPayload, } from 'ontime-types'; -import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; +import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import { RUNDOWN } from '../api/constants'; import { @@ -71,6 +72,7 @@ export const useEntryActions = () => { * @private */ const _addEntryMutation = useMutation({ + // TODO(v4): optimistic create entry mutationFn: postAddEntry, onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); @@ -83,7 +85,7 @@ export const useEntryActions = () => { */ const addEntry = useCallback( async (entry: Partial, options?: EventOptions) => { - const newEntry: TransientEventPayload = { ...entry }; + const newEntry: TransientEventPayload = { ...entry, id: generateId() }; // ************* CHECK OPTIONS specific to events if (isOntimeEvent(newEntry)) { @@ -188,6 +190,7 @@ export const useEntryActions = () => { id: previousData.id, title: previousData.title, order: previousData.order, + flatOrder: previousData.flatOrder, entries: newRundown, revision: -1, }); @@ -355,6 +358,7 @@ export const useEntryActions = () => { id: previousRundown.id, title: previousRundown.title, order: previousRundown.order, + flatOrder: previousRundown.flatOrder, entries: newRundown, revision: -1, }); @@ -399,17 +403,14 @@ export const useEntryActions = () => { if (previousData) { // optimistically update object - const newOrder = previousData.order.filter((id) => !entryIds.includes(id)); - const newRundown = { ...previousData.entries }; - for (const eventId of entryIds) { - delete newRundown[eventId]; - } + const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData); queryClient.setQueryData(RUNDOWN, { id: previousData.id, title: previousData.title, - order: newOrder, - entries: newRundown, + order, + flatOrder, + entries, revision: -1, }); } @@ -462,8 +463,9 @@ export const useEntryActions = () => { queryClient.setQueryData(RUNDOWN, { id: previousData?.id ?? 'default', title: previousData?.title ?? '', - entries: {}, order: [], + flatOrder: [], + entries: {}, revision: -1, }); @@ -542,6 +544,7 @@ export const useEntryActions = () => { id: previousData.id, title: previousData.title, order: newOrder, + flatOrder: previousData.flatOrder, entries: previousData.entries, revision: -1, }); @@ -613,6 +616,7 @@ export const useEntryActions = () => { id: previousData.id, title: previousData.title, order: previousData.order, + flatOrder: previousData.flatOrder, entries: newRundown, revision: -1, }); @@ -662,3 +666,32 @@ export const useEntryActions = () => { updateCustomField, }; }; + +/** + * Utility to optimistically delete entries from client cache + */ +function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) { + const entries = { ...rundown.entries }; + let order = [...rundown.order]; + let flatOrder = [...rundown.flatOrder]; + + for (let i = 0; i < entryIds.length; i++) { + const entry = entries[entryIds[i]]; + deleteEntry(entry); + } + + function deleteEntry(entry: OntimeEntry) { + if (isOntimeEvent(entry) && entry.parent) { + const parent = entries[entry.parent] as OntimeBlock; + parent.events = parent.events.filter((event) => event !== entry.id); + parent.numEvents -= 1; + } else { + order = order.filter((id) => id !== entry.id); + } + + delete entries[entry.id]; + flatOrder = flatOrder.filter((id) => id !== entry.id); + } + + return { entries, order, flatOrder }; +} diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 3104d66f5f..6ceb547bad 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -8,6 +8,7 @@ import { type Rundown, isOntimeBlock, isOntimeEvent, + OntimeEntry, Playback, SupportedEvent, } from 'ontime-types'; @@ -93,7 +94,7 @@ export default function Rundown({ data }: RundownProps) { ); const insertAtId = useCallback( - (type: SupportedEvent, id: MaybeString, above = false) => { + (patch: Partial & { type: SupportedEvent }, id: MaybeString, above = false) => { const options: EventOptions = id === null ? {} @@ -102,17 +103,10 @@ export default function Rundown({ data }: RundownProps) { before: above ? id : undefined, }; - if (type === SupportedEvent.Event) { - const newEvent = { - type: SupportedEvent.Event, - }; - if (!above && id) { - options.lastEventId = id; - } - addEntry(newEvent, options); - } else { - addEntry({ type }, options); + if (!above && id) { + options.lastEventId = id; } + addEntry(patch, options); }, [addEntry], ); @@ -208,14 +202,14 @@ export default function Rundown({ data }: RundownProps) { ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], - ['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }], - ['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }], + ['alt + E', () => insertAtId({ type: SupportedEvent.Event }, cursor), { preventDefault: true }], + ['alt + shift + E', () => insertAtId({ type: SupportedEvent.Event }, cursor, true), { preventDefault: true }], - ['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }], - ['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }], + ['alt + B', () => insertAtId({ type: SupportedEvent.Block }, cursor), { preventDefault: true }], + ['alt + shift + B', () => insertAtId({ type: SupportedEvent.Block }, cursor, true), { preventDefault: true }], - ['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }], - ['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }], + ['alt + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor), { preventDefault: true }], + ['alt + shift + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor, true), { preventDefault: true }], ['mod + C', () => setEntryCopyId(cursor)], ['mod + V', () => insertCopyAtId(cursor, entryCopyId)], @@ -260,7 +254,7 @@ export default function Rundown({ data }: RundownProps) { }; if (statefulEntries.length < 1) { - return insertAtId(SupportedEvent.Event, cursor)} />; + return insertAtId({ type: SupportedEvent.Event }, cursor)} />; } // 1. gather presentation options @@ -292,15 +286,21 @@ export default function Rundown({ data }: RundownProps) { return ( {isEditMode && (hasCursor || isFirst) && ( - + )} {isOntimeBlock(entry) ? ( {entry.events.length === 0 && ( - insertAtId(SupportedEvent.Event, cursor)} /> + insertAtId({ type: SupportedEvent.Event, parent: entry.id }, entry.id)} + /> )} {entry.events.map((eventId, nestedIndex) => { const nestedEntry = entries[eventId]; + if (!nestedEntry) { + return null; + } + const nestedRundownMeta = process(nestedEntry); const isFirstInGroup = nestedIndex === 0; const isLastInGroup = nestedIndex === entry.events.length - 1; @@ -312,7 +312,10 @@ export default function Rundown({ data }: RundownProps) { return ( {isEditMode && (hasNestedCursor || isFirstInGroup) && ( - + )}
{isEditMode && (hasNestedCursor || isLastInGroup) && ( - + )}
); @@ -371,7 +374,9 @@ export default function Rundown({ data }: RundownProps) {
)} - {isEditMode && (hasCursor || isLast) && } + {isEditMode && (hasCursor || isLast) && ( + + )}
); })} 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 a3fa9aec6a..f62d5555ca 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 @@ -5,6 +5,7 @@ margin: 0.25rem 0; font-size: calc(1rem - 3px); + margin-left: calc(2em + 0.5rem); } .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 76edd93f4f..2875307bdd 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -1,74 +1,69 @@ -import { memo, useCallback, useRef } from 'react'; +import { memo, useRef } from 'react'; import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; import { MaybeString, SupportedEvent } from 'ontime-types'; import { useEntryActions } from '../../../common/hooks/useEntryAction'; -import { useEmitLog } from '../../../common/stores/logger'; import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { previousEventId: MaybeString; - showBlocks?: boolean; + parentBlock: MaybeString; } export default memo(QuickAddBlock); function QuickAddBlock(props: QuickAddBlockProps) { - const { previousEventId, showBlocks } = props; + const { previousEventId, parentBlock } = props; const { addEntry } = useEntryActions(); - const { emitError } = useEmitLog(); 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: SupportedEvent.Event, + parent: parentBlock ?? null, + }, + { + 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, - }; - addEntry(newEvent, options); - break; - } - case 'delay': { - const options = { - lastEventId: previousEventId, - after: previousEventId, - }; - addEntry({ type: SupportedEvent.Delay }, options); - break; - } - case 'block': { - const options = { - lastEventId: previousEventId, - after: previousEventId, - }; - addEntry({ type: SupportedEvent.Block }, options); - break; - } - default: { - emitError(`Cannot create unknown event type: ${eventType}`); - break; - } - } - }, - [previousEventId, addEntry, emitError], - ); + const addDelay = () => { + addEntry( + // TODO(v4): add delays to blocks + { type: SupportedEvent.Delay }, + { + lastEventId: previousEventId, + after: previousEventId, + }, + ); + }; + + const addBlock = () => { + if (parentBlock !== null) { + return; + } + addEntry( + { type: SupportedEvent.Block }, + { + lastEventId: previousEventId, + after: previousEventId, + }, + ); + }; return (
- {showBlocks && ( + {parentBlock === null && ( -
- ); -} diff --git a/apps/client/src/features/rundown/Rundown.module.scss b/apps/client/src/features/rundown/Rundown.module.scss index a5da6dd8ec..97da8a4877 100644 --- a/apps/client/src/features/rundown/Rundown.module.scss +++ b/apps/client/src/features/rundown/Rundown.module.scss @@ -31,6 +31,7 @@ display: flex; gap: 0.5rem; align-items: center; + background-color: color-mix(in srgb, var(--user-bg, transparent) 10%, transparent 90%); } .entryIndex { diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 7951f9111b..7e2ef3be38 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -1,13 +1,23 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; -import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useHotkeys } from '@mantine/hooks'; +import { useHotkeys, useSessionStorage } from '@mantine/hooks'; import { type EntryId, type MaybeString, type Rundown, isOntimeBlock, isOntimeEvent, + OntimeBlock, OntimeEntry, Playback, SupportedEntry, @@ -30,9 +40,9 @@ import { useEntryCopy } from '../../common/stores/entryCopyStore'; import { cloneEvent } from '../../common/utils/eventsManager'; import BlockBlock from './block-block/BlockBlock'; +import BlockEnd from './block-block/BlockEnd'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; -import BlockEmpty from './BlockEmpty'; -import { makeRundownMetadata } from './rundown.utils'; +import { makeRundownMetadata, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -45,10 +55,16 @@ interface RundownProps { } export default function Rundown({ data }: RundownProps) { - const { order, entries } = data; - const [statefulEntries, setStatefulEntries] = useState(order); - + const { order, flatOrder, entries, id } = data; + // we create a copy of the rundown with a data structured aligned with what dnd-kit needs const featureData = useRundownEditor(); + const [sortableData, setSortableData] = useState(() => makeSortableList(flatOrder, entries)); + const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ + // we ensure that this is unique to the rundown + key: `rundown.${id}-editor-collapsed-groups`, + defaultValue: [], + }); + const { addEntry, reorderEntry, deleteEntry } = useEntryActions(); const { entryCopyId, setEntryCopyId } = useEntryCopy(); @@ -221,11 +237,11 @@ export default function Rundown({ data }: RundownProps) { // we copy the state from the store here // to workaround async updates on the drag mutations useEffect(() => { - setStatefulEntries(order); - }, [order]); + setSortableData(makeSortableList(flatOrder, entries)); + }, [flatOrder, entries]); + // in run mode, we follow selection useEffect(() => { - // in run mode, we follow selection if (appMode !== AppMode.Run || !featureData?.selectedEventId) { return; } @@ -233,6 +249,36 @@ export default function Rundown({ data }: RundownProps) { setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]); + /** + * Checks whether a block is collapsed + */ + const getIsCollapsed = useCallback( + (blockId: EntryId): boolean => { + return Boolean(collapsedGroups.find((id) => id === blockId)); + }, + [collapsedGroups], + ); + + /** + * Handles logic for collapsing groups + */ + const handleCollapseGroup = useCallback( + (collapsed: boolean, groupId: EntryId) => { + setCollapsedGroups((prev) => { + const isCollapsed = getIsCollapsed(groupId); + if (collapsed && !isCollapsed) { + const newSet = new Set(prev).add(groupId); + return [...newSet]; + } + if (!collapsed && isCollapsed) { + return [...prev].filter((id) => id !== groupId); + } + return prev; + }); + }, + [getIsCollapsed, setCollapsedGroups], + ); + /** * On drag end, we reorder the events */ @@ -245,7 +291,7 @@ export default function Rundown({ data }: RundownProps) { const toIndex = over.data.current?.sortable.index; // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates - setStatefulEntries((currentEntries) => { + setSortableData((currentEntries) => { return reorderArray(currentEntries, fromIndex, toIndex); }); reorderEntry(String(active.id), fromIndex, toIndex); @@ -253,7 +299,36 @@ export default function Rundown({ data }: RundownProps) { } }; - if (statefulEntries.length < 1) { + /** + * When we drag a block, we force collapse it + * This avoids strange scenarios like dropping a block inside itself + */ + const collapseDraggedBlocks = (event: DragStartEvent) => { + const isBlock = event.active.data.current?.type === 'block'; + if (isBlock) { + handleCollapseGroup(true, event.active.id as EntryId); + } + }; + + /** + * When we drag over a block, we expand it if it is collapsed + */ + const expandOverBlock = (event: DragOverEvent) => { + // if we are dragging a block, the drop operation is invalid so we dont expand + if (event.active.data.current?.type === 'block') { + return; + } + if (event.over?.data.current?.type !== 'block') { + return; + } + const blockId = event.over?.id as EntryId; + const isCollapsed = getIsCollapsed(blockId); + if (isCollapsed) { + handleCollapseGroup(false, blockId); + } + }; + + if (sortableData.length < 1) { return insertAtId({ type: SupportedEntry.Event }, cursor)} />; } @@ -261,121 +336,130 @@ export default function Rundown({ data }: RundownProps) { const isEditMode = appMode === AppMode.Edit; // 2. initialise rundown metadata - const process = makeRundownMetadata(featureData?.selectedEventId); + const { metadata, process } = makeRundownMetadata(featureData?.selectedEventId); + // keep a single reference to the metadata which we override for every entry + let rundownMetadata = metadata; return (
- - + +
- {statefulEntries.map((entryId, index) => { - // we iterate through a stateful copy of order to make the operations smoother + {sortableData.map((entryId, index) => { + const isFirst = index === 0; + const isLast = index === sortableData.length - 1; + + // the entry might be a pseudo block-end which does not generate metadata and should not be processed + if (entryId.startsWith('end-')) { + const parentId = entryId.split('end-')[1]; + const isBlockCollapsed = getIsCollapsed(parentId); + + if (isBlockCollapsed && isEditMode && isLast) { + return ; + } else { + const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour; + // if the previous element is selected, it will have its own QuickAddBlock + // we use thisId instead of previousEntryId because the block end does not process + // and it does not cause the reassignment of the iteration id to the previous entry + const showPrependingQuickAdd = isEditMode && cursor !== rundownMetadata.thisId; + return ( + + {showPrependingQuickAdd && ( + + )} + + {isEditMode && isLast && } + + ); + } + } + + // we iterate through a stateful copy of order to make the dnd operations smoother // this means that this can be out of sync with order until the useEffect runs // instead of writing all the logic guards, we simply short circuit rendering here const entry = entries[entryId]; - if (!entry) { + if (!entry) return null; + rundownMetadata = process(entry); + + // if the entry has a parent, and it is collapsed, render nothing + if ( + entry.type !== SupportedEntry.Block && + rundownMetadata.groupId !== null && + getIsCollapsed(rundownMetadata.groupId) + ) { return null; } - const rundownMeta = process(entry); - const isFirst = index === 0; - const isLast = index === order.length - 1; const isNext = featureData?.nextEventId === entry.id; const hasCursor = entry.id === cursor; + /** + * Outside a block, the value will be undefined + * 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 = rundownMetadata.groupColour === '' ? '#303030' : rundownMetadata.groupColour; + return ( {isEditMode && (hasCursor || isFirst) && ( - + )} {isOntimeBlock(entry) ? ( - - {entry.events.length === 0 && ( - insertAtId({ type: SupportedEntry.Event, parent: entry.id }, entry.id)} - /> - )} - {entry.events.map((eventId, nestedIndex) => { - const nestedEntry = entries[eventId]; - if (!nestedEntry) { - return null; - } - - const nestedRundownMeta = process(nestedEntry); - const isFirstInGroup = nestedIndex === 0; - const isLastInGroup = nestedIndex === entry.events.length - 1; - const hasNestedCursor = nestedEntry.id === cursor; - - if (!isOntimeEvent(nestedEntry)) { - return null; - } - return ( - - {isEditMode && (hasNestedCursor || isFirstInGroup) && ( - - )} - -
-
{nestedRundownMeta.eventIndex}
-
- -
-
- {isEditMode && (hasNestedCursor || isLastInGroup) && ( - - )} -
- ); - })} -
+ ) : ( -
- {isOntimeEvent(entry) &&
{rundownMeta.eventIndex}
} +
+ {isOntimeEvent(entry) &&
{rundownMetadata.eventIndex}
}
)} {isEditMode && (hasCursor || isLast) && ( - + )} ); diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 7e22b47207..bdee1045ea 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -179,6 +179,7 @@ export default function RundownEntry(props: RundownEntryProps) { isPast={isPast} isNext={isNext} skip={data.skip} + parent={data.parent} loaded={loaded} hasCursor={hasCursor} playback={playback} diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index c025126104..ebc612d09f 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -1,6 +1,6 @@ -import { OntimeBlock, OntimeEvent, SupportedEntry } from 'ontime-types'; +import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; -import { makeRundownMetadata } from '../rundown.utils'; +import { makeRundownMetadata, makeSortableList } from '../rundown.utils'; describe('makeRundownMetadata()', () => { it('processes nested rundown data', () => { @@ -21,7 +21,8 @@ describe('makeRundownMetadata()', () => { block: { id: 'block', type: SupportedEntry.Block, - events: ['11, 12, 13'], + events: ['11', '12', '13'], + colour: 'red', } as OntimeBlock, '11': { id: '11', @@ -73,7 +74,22 @@ describe('makeRundownMetadata()', () => { } as OntimeEvent, }; - const process = makeRundownMetadata(selectedEventId); + 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, @@ -82,11 +98,12 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['1'].id, eventIndex: 1, // UI indexes are 1 based isPast: true, - isNext: false, isNextDay: false, totalGap: 0, isLinkedToLoaded: false, isLoaded: false, + groupId: null, + groupColour: undefined, }); expect(process(demoEvents['block'])).toMatchObject({ @@ -96,11 +113,12 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['block'].id, eventIndex: 1, isPast: true, - isNext: false, isNextDay: false, totalGap: 0, isLinkedToLoaded: false, isLoaded: false, + groupId: 'block', + groupColour: 'red', }); expect(process(demoEvents['11'])).toMatchObject({ @@ -110,11 +128,12 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['11'].id, eventIndex: 2, isPast: true, - isNext: false, isNextDay: false, totalGap: 10, isLinkedToLoaded: false, isLoaded: false, + groupId: 'block', + groupColour: 'red', }); expect(process(demoEvents['12'])).toMatchObject({ @@ -124,11 +143,12 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['12'].id, eventIndex: 3, isPast: false, - isNext: false, isNextDay: false, totalGap: 10, isLinkedToLoaded: false, isLoaded: true, + groupId: 'block', + groupColour: 'red', }); expect(process(demoEvents['13'])).toMatchObject({ @@ -138,11 +158,12 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['13'].id, eventIndex: 4, isPast: false, - isNext: false, isNextDay: false, totalGap: 10, isLinkedToLoaded: true, isLoaded: false, + groupId: 'block', + groupColour: 'red', }); expect(process(demoEvents['2'])).toMatchObject({ @@ -152,11 +173,142 @@ describe('makeRundownMetadata()', () => { thisId: demoEvents['2'].id, eventIndex: 5, isPast: false, - isNext: 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 flatOrder = ['block-1', '11', '2', 'block-3', '31', '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(flatOrder, entries); + expect(sortableList).toEqual([ + 'block-1', + '11', + 'end-block-1', + '2', + 'block-3', + '31', + 'end-block-3', + 'block-4', + 'end-block-4', + ]); + }); + + it('closes dangling blocks', () => { + const flatOrder = ['block', '11', '12']; + 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(flatOrder, entries); + expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']); + }); + + it('handles a list with a with just blocks', () => { + const flatOrder = ['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(flatOrder, entries); + expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']); }); }); diff --git a/apps/client/src/features/rundown/_blockMixins.scss b/apps/client/src/features/rundown/_blockMixins.scss index 8f769edb35..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,7 +20,6 @@ $block-cursor-color: $orange-400; box-sizing: content-box; border: 1px solid $white-7; border-radius: $block-border-radius; - margin-block: 0.25rem; position: relative; color: $block-text-color; @@ -33,9 +32,11 @@ $block-cursor-color: $orange-400; 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 52830d6ead..df2a63b13b 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -4,24 +4,25 @@ @include block-styling; overflow: hidden; - min-width: 34rem; - display: grid; - grid-template-columns: 2rem 1fr auto; - grid-template-areas: - 'binder header' - 'content content' - 'footer footer'; + grid-template-columns: 2rem 1fr; + grid-template-areas: 'binder header'; align-items: center; + // 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: $gray-1050; // to override inline + background-color: var(--block-color, $gray-1050); color: $section-white; font-size: 1rem; display: grid; @@ -51,28 +52,23 @@ } .metaEntry { - font-size: calc(1rem - 3px); width: 4.5em; :first-child { + font-size: calc(1rem - 3px); color: $label-gray; } } - - .group { - background-color: color-mix(in srgb, var(--user-bg, $gray-1050) 10%, transparent 90%); - grid-area: content; - padding-right: 2px; - box-sizing: content-box; - } - - .footer { - grid-area: footer; - background-color: var(--user-bg, $gray-1050) ; - height: 0.25rem; - } } .drag { @include drag-style; + + &.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 fb42bc84a5..d2c9ef88b1 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,24 +1,25 @@ -import { PropsWithChildren, useRef } from 'react'; +import { useRef } from 'react'; import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { useSessionStorage } from '@mantine/hooks'; -import { OntimeBlock } from 'ontime-types'; +import { EntryId, OntimeBlock } from 'ontime-types'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import { formatDuration, formatTime } from '../../../common/utils/time'; import EditableBlockTitle from '../common/EditableBlockTitle'; +import { canDrop } from '../rundown.utils'; import style from './BlockBlock.module.scss'; interface BlockBlockProps { data: OntimeBlock; hasCursor: boolean; + collapsed: boolean; + onCollapse: (collapsed: boolean, groupId: EntryId) => void; } -export default function BlockBlock(props: PropsWithChildren) { - const { data, hasCursor, children } = props; - const [collapsed, setCollapsed] = useSessionStorage({ key: `block-${data.id}`, defaultValue: false }); +export default function BlockBlock(props: BlockBlockProps) { + const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); const { @@ -27,21 +28,30 @@ export default function BlockBlock(props: PropsWithChildren) { 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 binderColours = data.colour && getAccessibleColour(data.colour); - return (
) { }} >
- +
-
@@ -79,12 +94,6 @@ export default function BlockBlock(props: PropsWithChildren) {
- {!collapsed && ( -
- {children} -
- )} -
); } 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/delay-block/DelayBlock.module.scss b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss index 0b93a38e67..bebb4d1da7 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss @@ -3,6 +3,7 @@ .delay { @include block-styling; + margin-block: 0.25rem; background-color: $block-bg2; padding-right: 0.5rem; diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index 5b15fbcaed..53a9ceeaf8 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx @@ -25,14 +25,19 @@ export default function DelayBlock(props: DelayBlockProps) { 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, }; 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 2ddd307c22..73f6fd9a85 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -12,7 +12,7 @@ import { } from 'react-icons/io5'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { EndAction, 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'; @@ -45,6 +45,7 @@ interface EventBlockProps { isPast: boolean; isNext: boolean; skip: boolean; + parent: EntryId | null; loaded: boolean; hasCursor: boolean; playback?: Playback; @@ -86,6 +87,7 @@ export default function EventBlock(props: EventBlockProps) { isPast, isNext, skip = false, + parent, loaded, hasCursor, playback, @@ -193,6 +195,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/quick-add-block/QuickAddBlock.module.scss b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.module.scss index f62d5555ca..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); - margin-left: calc(2em + 0.5rem); + 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 a06b572531..a1c88c6c96 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -10,12 +10,13 @@ import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { previousEventId: MaybeString; parentBlock: MaybeString; + backgroundColor?: string; } export default memo(QuickAddBlock); function QuickAddBlock(props: QuickAddBlockProps) { - const { previousEventId, parentBlock } = props; + const { previousEventId, parentBlock, backgroundColor } = props; const { addEntry } = useEntryActions(); const doLinkPrevious = useRef(null); @@ -25,7 +26,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { addEntry( { type: SupportedEntry.Event, - parent: parentBlock ?? null, + parent: parentBlock, }, { after: previousEventId, @@ -38,8 +39,7 @@ function QuickAddBlock(props: QuickAddBlockProps) { const addDelay = () => { addEntry( - // TODO(v4): add delays to blocks - { type: SupportedEntry.Delay }, + { type: SupportedEntry.Delay, parent: parentBlock }, { lastEventId: previousEventId, after: previousEventId, @@ -60,8 +60,15 @@ function QuickAddBlock(props: QuickAddBlockProps) { ); }; + /** + * 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 ( -
+
+
diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index d484bb0ea3..2a3f6d59bf 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -11,6 +11,7 @@ import { deleteAllEntries, deleteEvent, editEvent, + dissolveBlock, reorderEntry, swapEvents, } from '../../services/rundown-service/RundownService.js'; @@ -126,6 +127,16 @@ export async function rundownApplyDelay(req: Request, res: Response) { + try { + const newRundown = await dissolveBlock(req.params.eventId); + res.status(200).send(newRundown); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + export async function rundownDelete(_req: Request, res: Response) { try { await deleteAllEntries(); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 68f55e1d3c..f3abb13210 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -5,6 +5,7 @@ import { rundownApplyDelay, rundownBatchPut, rundownDelete, + rundownDissolveBlock, rundownGetAll, rundownGetById, rundownGetCurrent, @@ -37,6 +38,7 @@ router.put('/batch', rundownBatchPutValidator, rundownBatchPut); router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); +router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); router.delete('/', rundownArrayOfIds, deletesEventById); router.delete('/all', rundownDelete); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index c8e2f2dbe3..08112275de 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -214,6 +214,22 @@ export async function applyDelay(delayId: EntryId) { notifyChanges({ timer: true, external: true }); } +/** + * Deletes a block from the rundown and moves all its children to the top level + */ +export async function dissolveBlock(blockId: EntryId) { + const scopedMutation = cache.mutateCache(cache.dissolveBlock); + const { newRundown } = await scopedMutation({ blockId }); + + // notify runtime that rundown has changed + updateRuntimeOnChange(); + + // we dont need to modify the timer since the grouping does not affect the runtime + notifyChanges({ external: true }); + + return newRundown; +} + /** * swaps two events * @param {string} from - id of event from diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index f53026d547..5c58d7e9c6 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -15,6 +15,7 @@ import { editCustomField, removeCustomField, customFieldChangelog, + dissolveBlock, } from '../rundownCache.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { ProcessedRundownMetadata } from '../rundownCache.utils.js'; @@ -765,6 +766,35 @@ describe('reorder() mutation', () => { }); }); +describe('dissolveBlock() mutation', () => { + it('should correctly dissolve a block into its events', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '2', '21', '22'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + '2': makeOntimeBlock({ id: '2', events: ['21', '22'] }), + '21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }), + '22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }), + }, + }); + + const { newRundown } = dissolveBlock({ + rundown, + blockId: '2', + }); + + expect(newRundown.order).toStrictEqual(['1', '21', '22']); + expect(newRundown.flatOrder).toStrictEqual(['1', '21', '22']); + expect(newRundown.entries['2']).toBeUndefined(); + expect(newRundown.entries).toMatchObject({ + '1': { id: '1', type: SupportedEntry.Event, cue: 'data1', parent: null }, + '21': { id: '21', type: SupportedEntry.Event, cue: 'data21', parent: null }, + '22': { id: '22', type: SupportedEntry.Event, cue: 'data22', parent: null }, + }); + }); +}); + describe('swap() mutation', () => { it('should correctly swap data between events', () => { const rundown = makeRundown({ diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 0b72b81dea..bb5a55b5fd 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -509,6 +509,38 @@ export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn return { newRundown: rundown, didMutate: true }; } +type DissolveBlockArgs = MutationParams<{ blockId: EntryId }>; +/** + * Deletes a block and moves all its children to the top level order + * Mutates the given rundown + * @throws if block ID not found + */ +export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): MutatingReturn { + const block = rundown.entries[blockId]; + if (!isOntimeBlock(block)) { + throw new Error('Block with ID not found'); + } + + // get the events from the block and merge them into the order where the block was + const nestedEvents = block.events; + const blockIndex = rundown.order.indexOf(blockId); + rundown.order.splice(blockIndex, 1, ...nestedEvents); + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId); + + // delete block from entries and remove its reference from the child events + delete rundown.entries[blockId]; + for (let i = 0; i < nestedEvents.length; i++) { + const eventId = nestedEvents[i]; + const entry = rundown.entries[eventId]; + if (!entry) { + throw new Error('Entry not found'); + } + (entry as OntimeEvent | OntimeDelay).parent = null; + } + + return { newRundown: rundown, didMutate: true }; +} + type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries From 4a839831614e5c1a239c9afc3b40f64c4fb2cf4f Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 16 May 2025 21:02:48 +0200 Subject: [PATCH 38/49] refactor: type cleanup and test improvements --- .../rundown/__tests__/rundown.utils.test.ts | 27 ++++++++++++++++--- .../rundown-service/RundownService.ts | 5 ++++ .../rundown-service/rundownCache.utils.ts | 1 + .../src/definitions/core/Rundown.type.ts | 3 ++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index ebc612d09f..e91bd01495 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -1,4 +1,4 @@ -import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; +import { OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; import { makeRundownMetadata, makeSortableList } from '../rundown.utils'; @@ -21,7 +21,7 @@ describe('makeRundownMetadata()', () => { block: { id: 'block', type: SupportedEntry.Block, - events: ['11', '12', '13'], + events: ['11', 'delay', '12', '13'], colour: 'red', } as OntimeBlock, '11': { @@ -36,6 +36,12 @@ describe('makeRundownMetadata()', () => { skip: false, linkStart: false, } as OntimeEvent, + delay: { + id: 'delay', + type: SupportedEntry.Delay, + parent: 'block', + duration: 0, + } as OntimeDelay, '12': { id: '12', type: SupportedEntry.Event, @@ -136,10 +142,25 @@ describe('makeRundownMetadata()', () => { 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['11'].id, + previousEntryId: demoEvents['delay'].id, thisId: demoEvents['12'].id, eventIndex: 3, isPast: false, diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 08112275de..117fc4c6fd 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -203,6 +203,11 @@ export async function reorderEntry(eventId: EntryId, from: number, to: number): return newRundown; } +/** + * Applies a delay into the rundown effectively changing the schedule + * The applied delay is deleted + * @param delayId + */ export async function applyDelay(delayId: EntryId) { const scopedMutation = cache.mutateCache(cache.applyDelay); await scopedMutation({ delayId }); diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index cebe7d979e..08e58e74a3 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -275,6 +275,7 @@ function processEntry( } else if (isOntimeDelay(currentEntry)) { // !!! this must happen after handling the links processedData.totalDelay += currentEntry.duration; + currentEntry.parent = childOfBlock; } if (!childOfBlock) { diff --git a/packages/types/src/definitions/core/Rundown.type.ts b/packages/types/src/definitions/core/Rundown.type.ts index 94be1caa55..1f76f38ac4 100644 --- a/packages/types/src/definitions/core/Rundown.type.ts +++ b/packages/types/src/definitions/core/Rundown.type.ts @@ -6,7 +6,8 @@ export type RundownEntries = Record; // we need to create a manual union type since keys cannot be used in type unions export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock; -export type ProjectRundowns = Record; +type RundownId = string; +export type ProjectRundowns = Record; export type Rundown = { id: string; From 0a44bfe783e3d185549bed6054fd391131afa3bf Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 16 May 2025 21:03:02 +0200 Subject: [PATCH 39/49] fix: collapsed blocks dont render children --- apps/client/src/features/rundown/Rundown.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index e60b136597..4d56e88764 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -362,6 +362,8 @@ export default function Rundown({ data }: RundownProps) { if (isBlockCollapsed && isEditMode && isLast) { return ; + } else if (isBlockCollapsed) { + return null; } else { const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour; // if the previous element is selected, it will have its own QuickAddBlock From 67f914bdc0536aa4e68ec076ea825b2e058e0241 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 17 May 2025 14:58:46 +0200 Subject: [PATCH 40/49] feat: create block from rundown empty --- .../src/features/rundown/Empty.module.scss | 7 +++++++ apps/client/src/features/rundown/Rundown.tsx | 2 +- .../src/features/rundown/RundownEmpty.tsx | 17 ++++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/client/src/features/rundown/Empty.module.scss b/apps/client/src/features/rundown/Empty.module.scss index 7ed3e524c9..50b47082bf 100644 --- a/apps/client/src/features/rundown/Empty.module.scss +++ b/apps/client/src/features/rundown/Empty.module.scss @@ -2,3 +2,10 @@ padding-block: 1.5rem; text-align: center; } + +.inline { + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; +} diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 4d56e88764..cb768f0c62 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -329,7 +329,7 @@ export default function Rundown({ data }: RundownProps) { }; if (sortableData.length < 1) { - return insertAtId({ type: SupportedEntry.Event }, cursor)} />; + return insertAtId({ type }, cursor)} />; } // 1. gather presentation options diff --git a/apps/client/src/features/rundown/RundownEmpty.tsx b/apps/client/src/features/rundown/RundownEmpty.tsx index 6ce8cb5b11..8f5d14fe09 100644 --- a/apps/client/src/features/rundown/RundownEmpty.tsx +++ b/apps/client/src/features/rundown/RundownEmpty.tsx @@ -1,12 +1,13 @@ import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; +import { SupportedEntry } from 'ontime-types'; import Empty from '../../common/components/state/Empty'; import style from './Empty.module.scss'; interface RundownEmptyProps { - handleAddNew: () => void; + handleAddNew: (type: SupportedEntry) => void; } export default function RundownEmpty(props: RundownEmptyProps) { @@ -14,10 +15,16 @@ export default function RundownEmpty(props: RundownEmptyProps) { return (
- - + +
+ + + +
); } From 7d5291e5e5efea1952fa3b8ad7fc56d113a45ce7 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 17 May 2025 15:17:00 +0200 Subject: [PATCH 41/49] feat: create group from entry selection --- apps/client/src/common/api/rundown.ts | 6 +- .../client/src/common/hooks/useEntryAction.ts | 74 ++++++++++++++++--- .../src/features/rundown/RundownEntry.tsx | 16 ++-- .../rundown/event-block/EventBlock.tsx | 4 +- .../src/features/rundown/useEventSelection.ts | 6 +- .../api-data/rundown/rundown.controller.ts | 11 +++ .../src/api-data/rundown/rundown.router.ts | 2 + .../src/api-data/rundown/rundown.utils.ts | 30 +++++++- .../rundown-service/RundownService.ts | 22 +++++- .../__tests__/rundownCache.test.ts | 34 +++++++++ .../services/rundown-service/rundownCache.ts | 65 +++++++++++++++- 11 files changed, 240 insertions(+), 30 deletions(-) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index fe66e8a321..23714f4a73 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -90,15 +90,15 @@ export async function requestApplyDelay(delayId: EntryId): Promise> { +export async function requestDissolveBlock(blockId: EntryId): Promise> { return axios.post(`${rundownPath}/dissolve/${blockId}`); } /** * HTTP request for grouping a list of entries into a block */ -export async function requestGroupEntries(entryIds: EntryId[]): Promise> { - return axios.post(`${rundownPath}/group`, { data: { ids: entryIds } }); +export async function requestGroupEntries(entryIds: EntryId[]): Promise> { + return axios.post(`${rundownPath}/group`, { ids: entryIds }); } /** diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 16ca95dea5..f6bd8be3f2 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -27,6 +27,7 @@ import { requestDeleteAll, requestDissolveBlock, requestEventSwap, + requestGroupEntries, SwapEntry, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; @@ -521,6 +522,19 @@ export const useEntryActions = () => { */ const _dissolveBlockMutation = useMutation({ mutationFn: requestDissolveBlock, + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); + }, onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), }); @@ -537,6 +551,43 @@ export const useEntryActions = () => { }, [_dissolveBlockMutation], ); + + /** + * Calls mutation to create a block with a selection + * @private + */ + const _groupEntriesMutation = useMutation({ + mutationFn: requestGroupEntries, + onSuccess: (response) => { + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + }); + + /** + * Create a block with a selection + */ + const groupEntries = useCallback( + async (entryIds: EntryId[]) => { + try { + await _groupEntriesMutation.mutateAsync(entryIds); + } catch (error) { + logAxiosError('Error grouping entries', error); + } + }, + [_groupEntriesMutation], + ); + /** * Calls mutation to reorder an entry * @private @@ -575,17 +626,17 @@ export const useEntryActions = () => { // Mutation finished, we update the rundown with the response onSuccess: (response) => { - if (response.data) { - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - } + if (!response.data) return; + + const { id, title, order, flatOrder, entries, revision } = response.data; + queryClient.setQueryData(RUNDOWN, { + id, + title, + order, + flatOrder, + entries, + revision, + }); }, // Mutation finished, failed or successful @@ -688,6 +739,7 @@ export const useEntryActions = () => { deleteAllEntries, dissolveBlock, getEntryById, + groupEntries, reorderEntry, swapEvents, updateEntry, diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index bdee1045ea..3d2cadd315 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -19,18 +19,17 @@ import EventBlock from './event-block/EventBlock'; import { useEventSelection } from './useEventSelection'; export type EventItemActions = - | 'set-cursor' | 'event' | 'event-before' | 'delay' | 'delay-before' | 'block' | 'block-before' + | 'swap' | 'delete' | 'clone' - | 'update' - | 'swap' - | 'clear-report'; + | 'group' + | 'update'; interface RundownEntryProps { type: SupportedEntry; @@ -66,7 +65,7 @@ export default function RundownEntry(props: RundownEntryProps) { isLinkedToLoaded, } = props; const { emitError } = useEmitLog(); - const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions(); + const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions(); const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection(); const removeOpenEvent = useCallback(() => { @@ -129,6 +128,13 @@ export default function RundownEntry(props: RundownEntryProps) { addEntry(newEvent, { after: data.id }); break; } + case 'group': { + if (selectedEvents.size > 1) { + clearMultiSelection(); + return groupEntries(Array.from(selectedEvents)); + } + break; + } case 'update': { // Handles and filters update requests const { field, value } = payload as FieldValue; diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 73f6fd9a85..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, @@ -26,7 +27,7 @@ import RundownIndicators from './RundownIndicators'; import style from './EventBlock.module.scss'; interface EventBlockProps { - eventId: string; + eventId: EntryId; cue: string; timeStart: number; timeEnd: number; @@ -144,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') }, ] : [ diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index 697e0750e7..f1591a7a4c 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils'; 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) => ({ diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index 2a3f6d59bf..d00d9a5a43 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -12,6 +12,7 @@ import { deleteEvent, editEvent, dissolveBlock, + groupEntries, reorderEntry, swapEvents, } from '../../services/rundown-service/RundownService.js'; @@ -137,6 +138,16 @@ export async function rundownDissolveBlock(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 }); + } +} + export async function rundownDelete(_req: Request, res: Response) { try { await deleteAllEntries(); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index f3abb13210..cd2dcb4a52 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -2,6 +2,7 @@ import express from 'express'; import { deletesEventById, + rundownAddToBlock, rundownApplyDelay, rundownBatchPut, rundownDelete, @@ -39,6 +40,7 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); +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 index c61cc3e424..d06d5a90ee 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -1,7 +1,7 @@ -import { OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; +import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types'; import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils'; -import { event as eventDef } from '../../models/eventsDefinition.js'; +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 { @@ -68,6 +68,32 @@ export const createEvent = (eventArgs: Partial, eventIndex: number 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, + numEvents: patch.events?.length ?? 0, + }; +} + /** * Function infers strategy for a patch with only partial timer data * @param end diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 117fc4c6fd..5c618210e4 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -14,9 +14,9 @@ import { } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; -import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; +import { delay as delayDef } from '../../models/eventsDefinition.js'; import { sendRefetch } from '../../adapters/websocketAux.js'; -import { createEvent } from '../../api-data/rundown/rundown.utils.js'; +import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; @@ -55,7 +55,7 @@ function generateEvent | Partial | P // TODO(v4): allow user to provide a larger patch of the block entry if (isOntimeBlock(eventData)) { - return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry; + return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry; } throw new Error('Invalid event type'); @@ -235,6 +235,22 @@ export async function dissolveBlock(blockId: EntryId) { return newRundown; } +/** + * Groups a list of entries into a block + */ +export async function groupEntries(entryIds: EntryId[]) { + const scopedMutation = cache.mutateCache(cache.groupEntries); + const { newRundown } = await scopedMutation({ entryIds }); + + // notify runtime that rundown has changed + updateRuntimeOnChange(); + + // we dont need to modify the timer since the grouping does not affect the runtime + notifyChanges({ external: true }); + + return newRundown; +} + /** * swaps two events * @param {string} from - id of event from diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 5c58d7e9c6..b9a732e759 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -16,6 +16,7 @@ import { removeCustomField, customFieldChangelog, dissolveBlock, + groupEntries, } from '../rundownCache.js'; import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; import { ProcessedRundownMetadata } from '../rundownCache.utils.js'; @@ -795,6 +796,39 @@ describe('dissolveBlock() mutation', () => { }); }); +describe('groupEntries() mutation', () => { + it('groups a list of existing events into a new block', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', parent: null }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + const { newRundown } = groupEntries({ + rundown, + entryIds: ['1', '2'], + }); + + const blockId = newRundown.order[0]; + expect(blockId).toStrictEqual(expect.any(String)); + expect(newRundown.order).toStrictEqual([expect.any(String), '3']); + expect(newRundown.flatOrder).toStrictEqual([expect.any(String), '1', '2', '3']); + expect(newRundown.entries).toMatchObject({ + [blockId]: { + type: SupportedEntry.Block, + events: ['1', '2'], + }, + '1': { id: '1', type: SupportedEntry.Event, parent: blockId }, + '2': { id: '2', type: SupportedEntry.Event, parent: blockId }, + '3': { id: '3', type: SupportedEntry.Event, parent: null }, + }); + }); +}); + describe('swap() mutation', () => { it('should correctly swap data between events', () => { const rundown = makeRundown({ diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index bb5a55b5fd..cbf2b764bb 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -11,11 +11,19 @@ import { OntimeEntry, Rundown, RundownEntries, + OntimeDelay, } from 'ontime-types'; -import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils'; +import { + generateId, + insertAtIndex, + reorderArray, + swapEventData, + customFieldLabelToKey, + mergeAtIndex, +} from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import { createPatch } from '../../api-data/rundown/rundown.utils.js'; +import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js'; import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; @@ -541,6 +549,59 @@ export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): Mutating return { newRundown: rundown, didMutate: true }; } +type GroupArgs = MutationParams<{ entryIds: EntryId[] }>; +/** + * Groups a list of entries into a block + * It ensures that the entries get reassigned parent and the block gets a list of events + * The block will be created at the index of the first event in the order, not at the lowest index + * Mutates the given rundown + * @throws if any of the entries is a block + * @throws if any of the entries is not found + */ +export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn { + const block = createBlock({ id: getUniqueId() }); + + const nestedEvents: EntryId[] = []; + let firstIndex = -1; + for (let i = 0; i < entryIds.length; i++) { + const entryId = entryIds[i]; + const entry = rundown.entries[entryId]; + if (!entry) { + throw new Error('Entry not found'); + } + + if (isOntimeBlock(entry)) { + throw new Error('Cannot group a block'); + } + + if (entry.parent !== null) { + throw new Error('Entry already has a parent'); + } + + // the block will be created at the first selected event position + // note that this is not the lowest index + if (firstIndex === -1) { + firstIndex = rundown.flatOrder.indexOf(entryId); + } + + nestedEvents.push(entryId); + entry.parent = block.id; + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); + rundown.order = rundown.order.filter((id) => id !== entryId); + } + + block.events = nestedEvents; + const insertIndex = Math.max(0, firstIndex); + // we have filtered the items from the order + // we will insert them now, with only the block at top level ... + rundown.order = insertAtIndex(insertIndex, block.id, rundown.order); + /// ... and the nested elements after the block in the flat order + rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder); + rundown.entries[block.id] = block; + + return { newRundown: rundown, didMutate: true }; +} + type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries From f68b3bf0577e8096ee492d836c306782fa5c28a4 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 19 May 2025 20:01:03 +0200 Subject: [PATCH 42/49] feat: duplicate groups --- apps/client/src/common/api/rundown.ts | 7 + .../client/src/common/hooks/useEntryAction.ts | 25 ++++ .../{eventsManager.test.ts => clone.test.ts} | 2 +- .../utils/{eventsManager.ts => clone.ts} | 0 apps/client/src/features/rundown/Rundown.tsx | 4 +- .../src/features/rundown/RundownEntry.tsx | 2 +- .../rundown/block-block/BlockBlock.tsx | 36 ++++- .../CuesheetTableMenuActions.tsx | 2 +- .../src/api-data/report/report.router.ts | 4 +- .../api-data/rundown/rundown.controller.ts | 15 +- .../src/api-data/rundown/rundown.router.ts | 10 +- .../api-data/rundown/rundown.validation.ts | 4 +- .../rundown-service/RundownService.ts | 40 ++++-- .../__tests__/rundownCache.test.ts | 113 ++++++++++++++- .../__tests__/rundownUtils.test.ts | 26 ++++ .../services/rundown-service/rundownCache.ts | 135 +++++++++++++++--- .../rundown-service/rundownCache.utils.ts | 36 +++++ .../services/rundown-service/rundownUtils.ts | 39 ++--- .../runtime-service/RuntimeService.ts | 3 +- packages/utils/src/common/arrayUtils.test.ts | 31 +++- 20 files changed, 447 insertions(+), 87 deletions(-) rename apps/client/src/common/utils/__tests__/{eventsManager.test.ts => clone.test.ts} (97%) rename apps/client/src/common/utils/{eventsManager.ts => clone.ts} (100%) create mode 100644 apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 23714f4a73..1a6e634193 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -87,6 +87,13 @@ export async function requestApplyDelay(delayId: EntryId): Promise> { + return axios.post(`${rundownPath}/clone/${entryId}`); +} + /** * HTTP request for dissolving of a block */ diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index f6bd8be3f2..942dc6f55e 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -20,6 +20,7 @@ import { deleteEntries, patchReorderEntry, postAddEntry, + postCloneEntry, putBatchEditEvents, putEditEntry, ReorderEntry, @@ -164,6 +165,29 @@ export const useEntryActions = () => { ], ); + /** + * Calls mutation to clone a selection + * @private + */ + const _cloneMutation = useMutation({ + mutationFn: postCloneEntry, + onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }), + }); + + /** + * Clone a selection + */ + const clone = useCallback( + async (entryId: EntryId) => { + try { + await _cloneMutation.mutateAsync(entryId); + } catch (error) { + logAxiosError('Error cloning entry', error); + } + }, + [_cloneMutation], + ); + /** * Calls mutation to update existing entry * @private @@ -735,6 +759,7 @@ export const useEntryActions = () => { addEntry, applyDelay, batchUpdateEvents, + clone, deleteEntry, deleteAllEntries, dissolveBlock, diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/clone.test.ts similarity index 97% rename from apps/client/src/common/utils/__tests__/eventsManager.test.ts rename to apps/client/src/common/utils/__tests__/clone.test.ts index b662c477da..97fd32d9bd 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/clone.test.ts @@ -1,6 +1,6 @@ import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types'; -import { cloneEvent } from '../eventsManager'; +import { cloneEvent } from '../clone'; describe('cloneEvent()', () => { it('creates a stem from a given event', () => { diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/clone.ts similarity index 100% rename from apps/client/src/common/utils/eventsManager.ts rename to apps/client/src/common/utils/clone.ts diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index cb768f0c62..7043534560 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -37,7 +37,7 @@ import useFollowComponent from '../../common/hooks/useFollowComponent'; import { useRundownEditor } from '../../common/hooks/useSocket'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { useEntryCopy } from '../../common/stores/entryCopyStore'; -import { cloneEvent } from '../../common/utils/eventsManager'; +import { cloneEvent } from '../../common/utils/clone'; import BlockBlock from './block-block/BlockBlock'; import BlockEnd from './block-block/BlockEnd'; @@ -329,7 +329,7 @@ export default function Rundown({ data }: RundownProps) { }; if (sortableData.length < 1) { - return insertAtId({ type }, cursor)} />; + return addEntry({ type })} />; } // 1. gather presentation options diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 3d2cadd315..d20948af30 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -12,7 +12,7 @@ import { import { useEntryActions } from '../../common/hooks/useEntryAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import { useEmitLog } from '../../common/stores/logger'; -import { cloneEvent } from '../../common/utils/eventsManager'; +import { cloneEvent } from '../../common/utils/clone'; import DelayBlock from './delay-block/DelayBlock'; import EventBlock from './event-block/EventBlock'; diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index 3e88d4907d..adaa4bc346 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,6 +1,14 @@ import { useRef } from 'react'; -import { IoChevronDown, IoChevronUp, IoEllipsisHorizontal, IoReorderTwo } from 'react-icons/io5'; -import { IconButton, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react'; +import { + IoChevronDown, + IoChevronUp, + IoDuplicateOutline, + IoEllipsisHorizontal, + IoFolderOpenOutline, + IoReorderTwo, + IoTrash, +} from 'react-icons/io5'; +import { IconButton, Menu, MenuButton, MenuItem, MenuList, Portal } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { EntryId, OntimeBlock } from 'ontime-types'; @@ -23,7 +31,7 @@ interface BlockBlockProps { export default function BlockBlock(props: BlockBlockProps) { const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); - const { dissolveBlock } = useEntryActions(); + const { clone, dissolveBlock, deleteEntry } = useEntryActions(); const { attributes: dragAttributes, @@ -52,6 +60,8 @@ export default function BlockBlock(props: BlockBlockProps) { cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default', }; + const hasChildren = data.events.length > 0; + return (
- - dissolveBlock(data.id)}>Dissolve Block - + + + } onClick={() => clone(data.id)}> + Clone Block + + {hasChildren && ( + } onClick={() => dissolveBlock(data.id)}> + Dissolve Block + + )} + } onClick={() => deleteEntry([data.id])}> + Delete Block + + + onCollapse(!collapsed, data.id)} color='#e2e2e2' // $gray-200 variant='ontime-ghosted' 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 d3214f3f7a..3e924282bb 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 @@ -3,7 +3,7 @@ import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react'; import { isOntimeEvent, SupportedEntry } from 'ontime-types'; import { useEntryActions } from '../../../../common/hooks/useEntryAction'; -import { cloneEvent } from '../../../../common/utils/eventsManager'; +import { cloneEvent } from '../../../../common/utils/clone'; interface CuesheetTableMenuActionsProps { eventId: string; 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/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index d00d9a5a43..ac743bbe31 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -15,6 +15,7 @@ import { groupEntries, reorderEntry, swapEvents, + cloneEntry, } from '../../services/rundown-service/RundownService.js'; import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; @@ -120,7 +121,7 @@ export async function rundownSwap(req: Request, res: Response) { try { - await applyDelay(req.params.eventId); + await applyDelay(req.params.entryId); res.status(200).send({ message: 'Delay applied' }); } catch (error) { const message = getErrorMessage(error); @@ -128,9 +129,19 @@ export async function rundownApplyDelay(req: Request, res: Response) { + try { + 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 rundownDissolveBlock(req: Request, res: Response) { try { - const newRundown = await dissolveBlock(req.params.eventId); + const newRundown = await dissolveBlock(req.params.entryId); res.status(200).send(newRundown); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index cd2dcb4a52..44e1bcc97a 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -5,6 +5,7 @@ import { rundownAddToBlock, rundownApplyDelay, rundownBatchPut, + rundownCloneEntry, rundownDelete, rundownDissolveBlock, rundownGetAll, @@ -16,7 +17,7 @@ import { rundownSwap, } from './rundown.controller.js'; import { - paramsMustHaveEventId, + paramsMustHaveEntryId, rundownArrayOfIds, rundownBatchPutValidator, rundownPostValidator, @@ -29,7 +30,7 @@ export const router = express.Router(); router.get('/', rundownGetAll); router.get('/current', rundownGetCurrent); -router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend +router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend router.post('/', rundownPostValidator, rundownPost); @@ -38,8 +39,9 @@ router.put('/batch', rundownBatchPutValidator, rundownBatchPut); router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); -router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay); -router.post('/dissolve/:eventId', paramsMustHaveEventId, rundownDissolveBlock); +router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay); +router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); +router.post('/dissolve/:entryId', paramsMustHaveEntryId, rundownDissolveBlock); router.post('/group', rundownArrayOfIds, rundownAddToBlock); router.delete('/', rundownArrayOfIds, deletesEventById); diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index bdd07a6180..eb018d8afc 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -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); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 5c618210e4..413571b5c9 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -21,7 +21,7 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getInsertionPosition } from './rundownUtils.js'; +import { getPreviousId } from './rundownUtils.js'; type CompleteEntry = T extends Partial @@ -70,11 +70,12 @@ export async function addEvent(eventData: EventPostPayload): Promise { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -625,13 +626,60 @@ describe('generate() v4', () => { }); describe('add() mutation', () => { - test('adds an event to the rundown', () => { + test('adds an event an empty rundown', () => { const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); const rundown = makeRundown({}); - const { newRundown } = add({ atIndex: 0, entry: mockEvent, parent: null, rundown }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); expect(newRundown.order.length).toBe(1); expect(newRundown.entries['mock']).toMatchObject(mockEvent); }); + + test('adds an event at the top if no afterId is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1'], + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: '1' }), + }, + }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown }); + expect(newRundown.order).toStrictEqual(['mock', '1']); + expect(newRundown.flatOrder).toStrictEqual(['mock', '1']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the top of the block if no after is given', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: '1', rundown }); + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', 'mock', '1a']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); + + test('adds an event at the a given location inside a block', () => { + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + flatOrder: ['1', '1a'], + order: ['1'], + entries: { + '1': makeOntimeBlock({ id: '1' }), + '1a': makeOntimeEvent({ id: '1a', parent: '1' }), + }, + }); + const { newRundown } = add({ afterId: '1a', entry: mockEvent, parent: '1', rundown }); + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', 'mock']); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); + }); }); describe('remove() mutation', () => { @@ -767,6 +815,65 @@ describe('reorder() mutation', () => { }); }); +describe('clone() mutation', () => { + it('clones an event and adds it to the rundown', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); + + expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); + expect(newRundown.flatOrder).toStrictEqual(['1', newEvent!.id]); + }); + + it('clones an event inside a block and adds it to the rundown', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1', '1a'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1a' }); + + expect(newRundown.order).toStrictEqual(['1']); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', newEvent!.id]); + expect(newRundown.entries['1']).toMatchObject({ events: ['1a', newEvent!.id] }); + expect(newRundown.entries[newEvent!.id]).toMatchObject({ + type: SupportedEntry.Event, + parent: '1', + cue: 'nested', + }); + }); + + it('clones a block and its nested elements', () => { + const rundown = makeRundown({ + order: ['1'], + flatOrder: ['1', '1a'], + entries: { + '1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }), + '1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }), + }, + }); + + const { newRundown, newEvent } = clone({ rundown, entryId: '1' }); + + expect(newRundown.order).toStrictEqual(['1', newEvent!.id]); + expect(newRundown.flatOrder).toStrictEqual(['1', '1a', expect.any(String), expect.any(String)]); + expect(newRundown.entries[newEvent!.id]).toMatchObject({ + type: SupportedEntry.Block, + events: [expect.any(String)], + }); + }); +}); + describe('dissolveBlock() mutation', () => { it('should correctly dissolve a block into its events', () => { const rundown = makeRundown({ diff --git a/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts new file mode 100644 index 0000000000..eefd30140e --- /dev/null +++ b/apps/server/src/services/rundown-service/__tests__/rundownUtils.test.ts @@ -0,0 +1,26 @@ +import { getPreviousId } from '../rundownUtils.js'; + +// Mock cache module +vi.mock('../rundownCache.js', () => ({ + getEventOrder: () => ({ + flatOrder: ['a', 'b', 'c', 'd'], + }), +})); + +describe('getPreviousId', () => { + it('returns afterId if provided', () => { + expect(getPreviousId('b')).toBe('b'); + }); + + it('returns the previous id before beforeId if provided', () => { + expect(getPreviousId(undefined, 'c')).toBe('b'); + }); + + it('returns undefined if neither afterId nor beforeId is provided', () => { + expect(getPreviousId()).toBeUndefined(); + }); + + it('returns undefined if beforeId is not found', () => { + expect(getPreviousId(undefined, 'z')).toBeUndefined(); + }); +}); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index cbf2b764bb..9a50f81ab0 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -27,7 +27,14 @@ import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.j import type { RundownMetadata } from './rundown.types.js'; import { apply } from './delayUtils.js'; -import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js'; +import { + cloneBlock, + cloneEntry, + hasChanges, + isDataStale, + makeRundownMetadata, + type ProcessedRundownMetadata, +} from './rundownCache.utils.js'; let currentRundownId: EntryId = ''; let currentRundown: Rundown = { @@ -271,6 +278,7 @@ export function getMetadata(): Readonly export type RundownOrder = { order: EntryId[]; + flatOrder: EntryId[]; timedEventsOrder: EntryId[]; playableEventsOrder: EntryId[]; }; @@ -284,6 +292,7 @@ export function getEventOrder(): Readonly { } return { order: currentRundown.order, + flatOrder: currentRundown.flatOrder, timedEventsOrder: rundownMetadata.timedEventOrder, playableEventsOrder: rundownMetadata.playableEventOrder, }; @@ -333,41 +342,69 @@ export function mutateCache(mutation: MutatingFn) { return scopedMutation; } -type AddArgs = MutationParams<{ atIndex: number; parent: EntryId | null; entry: OntimeEntry }>; +type AddArgs = MutationParams<{ afterId?: string; parent: EntryId | null; entry: OntimeEntry }>; /** - * Add entry to rundown + * Add entry to rundown, handles the following cases: + * - 1. add entry in block, after a given entry + * - 2. add entry in block, at the beginning + * - 3. add entry to the rundown, after a given entry + * - 4. add entry to the rundown, at the beginning */ -export function add({ rundown, atIndex, parent, entry }: AddArgs): Required { - const newEntry: OntimeEntry = { ...entry }; - - rundown.entries[newEntry.id] = newEntry; - +export function add({ rundown, afterId, parent, entry }: AddArgs): Required { if (parent) { const parentBlock = rundown.entries[parent] as OntimeBlock; - parentBlock.events = insertAtIndex(atIndex, newEntry.id, parentBlock.events); + if (afterId) { + const atEventsIndex = parentBlock.events.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events); + const atFlatIndex = rundown.flatOrder.indexOf(parent) + 1; + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } } else { - rundown.order = insertAtIndex(atIndex, newEntry.id, rundown.order); + if (afterId) { + const atOrderIndex = rundown.order.indexOf(afterId) + 1; + const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1; + rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder); + } else { + rundown.order = insertAtIndex(0, entry.id, rundown.order); + rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder); + } } + // either way, we insert the entry into the rundown + rundown.entries[entry.id] = entry; setIsStale(); - return { newRundown: rundown, changeList: [], newEvent: newEntry, didMutate: true }; + return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true }; } type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>; /** * Remove entries in a rundown - * It needs to ensure that the parent block is updated + * It handles element relationships specifically when dealing with nested items + * - when removing a nested item, remove the reference from the parent block + * - when removing a block, remove all nested items */ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { - let didMutate = false; + /** + * changelist will hold a list of entries that need to be removed + * it will then be returned to the caller as a list of actually deleted entries + */ + const changeList: EntryId[] = []; for (let i = 0; i < eventIds.length; i++) { const entry = rundown.entries[eventIds[i]]; - if (isOntimeBlock(entry) || !entry.parent) { - // top level events can simply be removed from the order - // the deletion process and the flatOrder are handled globally - rundown.order = rundown.order.filter((id) => id !== eventIds[i]); - } else { + // add the top level entry to the changeList + changeList.push(entry.id); + + if (isOntimeBlock(entry)) { + // for ontime blocks, we need to iterate through the children and delete them + changeList.concat([...entry.events]); + } else if (entry.parent) { + // at this point, we are handling entries inside a block, so we need to remove the references const parentBlock = rundown.entries[entry.parent] as OntimeBlock; const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]); @@ -383,14 +420,19 @@ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { }, }); } + } - didMutate = true; - rundown.flatOrder = rundown.flatOrder.filter((id) => id !== eventIds[i]); - delete rundown.entries[eventIds[i]]; + // delete all entries in the changeList + for (let i = 0; i < changeList.length; i++) { + const entryId = changeList[i]; + rundown.order = rundown.order.filter((id) => id !== entryId); + rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId); + delete rundown.entries[entryId]; } + const didMutate = changeList.length > 0; if (didMutate) setIsStale(); - return { newRundown: rundown, didMutate }; + return { newRundown: rundown, didMutate, changeList }; } /** @@ -517,6 +559,55 @@ export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn return { newRundown: rundown, didMutate: true }; } +type CloneEntryArgs = MutationParams<{ entryId: EntryId }>; +/** + * Apply a delay + * Mutates the given rundown + */ +export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn { + const entry = rundown.entries[entryId]; + if (!entry) { + throw new Error('Entry not found'); + } + + if (isOntimeBlock(entry)) { + const newBlock = cloneBlock(entry, getUniqueId()); + const nestedIds: EntryId[] = []; + + for (let i = 0; i < entry.events.length; i++) { + const nestedEntryId = entry.events[i]; + const nestedEntry = rundown.entries[nestedEntryId]; + if (!nestedEntry) { + continue; + } + + // clone the event and assign it to the new block + const newNestedEntry = cloneEntry(nestedEntry, getUniqueId()); + (newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id; + + nestedIds.push(newNestedEntry.id); + // we immediately insert the nested entries into the rundown + rundown.entries[newNestedEntry.id] = newNestedEntry; + } + // indexes + 1 since we are inserting after the cloned block + const atIndex = rundown.order.indexOf(entryId) + 1; + // we need to find the index of the last entry + const lastNestedIdInOriginal = entry.events.at(-1) ?? '0'; + const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1; + + newBlock.events = nestedIds; + newBlock.title = `${entry.title} (copy)`; + + rundown.entries[newBlock.id] = newBlock; + rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order); + rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder); + + return { newRundown: rundown, didMutate: true, newEvent: newBlock }; + } else { + return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) }); + } +} + type DissolveBlockArgs = MutationParams<{ blockId: EntryId }>; /** * Deletes a block and moves all its children to the top level order diff --git a/apps/server/src/services/rundown-service/rundownCache.utils.ts b/apps/server/src/services/rundown-service/rundownCache.utils.ts index 08e58e74a3..672430a8c7 100644 --- a/apps/server/src/services/rundown-service/rundownCache.utils.ts +++ b/apps/server/src/services/rundown-service/rundownCache.utils.ts @@ -10,6 +10,8 @@ import { isOntimeDelay, PlayableEvent, RundownEntries, + OntimeDelay, + OntimeBlock, } from 'ontime-types'; import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils'; @@ -286,3 +288,37 @@ function processEntry( return { processedData, processedEntry: currentEntry }; } + +export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent { + const newEntry = structuredClone(entry); + newEntry.id = newId; + newEntry.revision = 0; + return newEntry; +} + +export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay { + const newEntry = structuredClone(entry); + newEntry.id = newId; + return newEntry; +} + +export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock { + const newEntry = structuredClone(entry); + newEntry.id = newId; + + // in blocks, we need to remove the events references + newEntry.events = []; + newEntry.revision = 0; + return newEntry; +} + +export function cloneEntry(entry: T, newId: EntryId): T { + if (isOntimeEvent(entry)) { + return cloneEvent(entry, newId) as T; + } else if (isOntimeDelay(entry)) { + return cloneDelay(entry, newId) as T; + } else if (entry.type === 'block') { + return cloneBlock(entry as OntimeBlock, newId) as T; + } + throw new Error(`Unsupported entry type for cloning: ${entry}`); +} diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index 3e4531d66e..ac51f62c31 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -6,7 +6,6 @@ import { EntryId, RundownEntries, ProjectRundowns, - OntimeBlock, } from 'ontime-types'; import * as cache from './rundownCache.js'; @@ -175,37 +174,21 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): return rundowns[rundownId]; } -export function getInsertionPosition( - parentId: EntryId | null, - afterId?: EntryId, - beforeId?: EntryId, -): { atIndex: number; afterId: EntryId | undefined } { +/** + * Receives an insertion order and returns the reference to an event ID + * after which we will insert the new event + */ +export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined { if (afterId) { - const order = selectOrderList(parentId); - return { - atIndex: order.findIndex((id) => id === afterId) + 1, - afterId, - }; + return afterId; } if (beforeId) { - const order = selectOrderList(parentId); - const atIndex = order.findIndex((id) => id === beforeId); - return { - atIndex, - afterId: order[atIndex - 1] ?? null, - }; + const flatOrder = cache.getEventOrder().flatOrder; + const atIndex = flatOrder.findIndex((id) => id === beforeId); + if (atIndex < 1) return undefined; + return flatOrder[atIndex - 1]; } - return { - atIndex: 0, - afterId: undefined, - }; - - function selectOrderList(parentId: EntryId | null) { - if (parentId) { - return (getEntryWithId(parentId) as OntimeBlock).events; - } - return cache.getEventOrder().order; - } + return; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index cb6d091913..81f5e66a72 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -1,5 +1,6 @@ import { EndAction, + EntryId, isOntimeEvent, isPlayableEvent, LogOrigin, @@ -238,7 +239,7 @@ class RuntimeService { * Called when the underlying data has changed, * we check if the change affects the runtime */ - public notifyOfChangedEvents(affectedIds?: string[]) { + public notifyOfChangedEvents(affectedIds?: EntryId[]) { const state = runtimeState.getState(); const hasLoadedElements = state.eventNow !== null || state.eventNext !== null; if (!hasLoadedElements) { diff --git a/packages/utils/src/common/arrayUtils.test.ts b/packages/utils/src/common/arrayUtils.test.ts index bc0a564740..8f91775deb 100644 --- a/packages/utils/src/common/arrayUtils.test.ts +++ b/packages/utils/src/common/arrayUtils.test.ts @@ -1,4 +1,4 @@ -import { deleteAtIndex, insertAtIndex, reorderArray } from './arrayUtils.js'; +import { deleteAtIndex, insertAtIndex, mergeAtIndex, reorderArray } from './arrayUtils.js'; describe('insertAtIndex', () => { it('should insert an item at the beginning of the array', () => { @@ -23,7 +23,34 @@ describe('insertAtIndex', () => { const array = [1, 2, 3]; const result = insertAtIndex(1, 5, array); expect(result).toEqual([1, 5, 2, 3]); - expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged + expect(array).toEqual([1, 2, 3]); + }); +}); + +describe('mergeAtIndex', () => { + it('should insert an item at the beginning of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(0, ['a', 'b'], array); + expect(result).toEqual(['a', 'b', '1', '2', '3']); + }); + + it('should insert an item at the end of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(3, ['a', 'b'], array); + expect(result).toEqual(['1', '2', '3', 'a', 'b']); + }); + + it('should insert an item in the middle of the array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(2, ['a', 'b'], array); + expect(result).toEqual(['1', '2', 'a', 'b', '3']); + }); + + it('should return a new array and not modify the original array', () => { + const array = ['1', '2', '3']; + const result = mergeAtIndex(5, ['a', 'b'], array); + expect(result).toEqual(['1', '2', '3', 'a', 'b']); + expect(array).toEqual(['1', '2', '3']); }); }); From b0e42811a5da24ad32d090099a2a95a8eb69f9e4 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 19 May 2025 22:09:28 +0200 Subject: [PATCH 43/49] refactor: remove trivially inferred numEvents --- apps/client/src/common/hooks/useEntryAction.ts | 1 - apps/client/src/features/rundown/block-block/BlockBlock.tsx | 2 +- apps/server/src/api-data/rundown/rundown.utils.ts | 1 - apps/server/src/models/demoProject.ts | 3 --- apps/server/src/models/eventsDefinition.ts | 1 - .../services/rundown-service/__tests__/rundownCache.test.ts | 4 ---- apps/server/src/services/rundown-service/rundownCache.ts | 2 -- apps/server/src/user/styles/override.css | 1 + apps/server/test-db/db.json | 3 +-- e2e/tests/fixtures/e2e-test-db.json | 6 ++---- packages/types/src/definitions/core/OntimeEvent.type.ts | 1 - 11 files changed, 5 insertions(+), 20 deletions(-) diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 942dc6f55e..85d28b3455 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -792,7 +792,6 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) { } else { const parent = entries[entry.parent] as OntimeBlock; parent.events = parent.events.filter((event) => event !== entry.id); - parent.numEvents -= 1; } delete entries[entry.id]; diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index adaa4bc346..40f88d5a7f 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -134,7 +134,7 @@ export default function BlockBlock(props: BlockBlockProps) {
Events
-
{data.numEvents}
+
{data.events.length}
diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index d06d5a90ee..064fcd0529 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -90,7 +90,6 @@ export function createBlock(patch?: Partial): OntimeBlock { endTime: null, duration: 0, isFirstLinked: false, - numEvents: patch.events?.length ?? 0, }; } diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 1a18be57d0..83432abdf0 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -52,7 +52,6 @@ export const demoDb: DatabaseModel = { endTime: null, duration: 0, isFirstLinked: false, - numEvents: 0, custom: { song: 'Sekret', artist: 'Ronela Hajati', @@ -219,7 +218,6 @@ export const demoDb: DatabaseModel = { endTime: null, duration: 0, isFirstLinked: false, - numEvents: 0, }, '1c420': { type: SupportedEntry.Event, @@ -382,7 +380,6 @@ export const demoDb: DatabaseModel = { endTime: null, duration: 0, isFirstLinked: false, - numEvents: 0, }, '503c4': { type: SupportedEntry.Event, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index e8b11c7fc8..1bf717db7f 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -54,5 +54,4 @@ export const block: Omit = { endTime: null, // calculated at runtime duration: 0, // calculated at runtime isFirstLinked: false, // calculated at runtime - numEvents: 0, // calculated at runtime }; diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index a19d7761a1..44d817a0a8 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -550,7 +550,6 @@ describe('generate() v4', () => { endTime: 400, duration: 300, isFirstLinked: false, - numEvents: 3, }, '100': { type: SupportedEntry.Event, parent: '1' }, '200': { type: SupportedEntry.Event, parent: '1' }, @@ -591,7 +590,6 @@ describe('generate() v4', () => { endTime: 400, duration: 300, isFirstLinked: false, - numEvents: 3, }, '101': { parent: '1', gap: 90, linkStart: false }, '102': { parent: '1' }, @@ -603,7 +601,6 @@ describe('generate() v4', () => { endTime: 800, duration: 300, isFirstLinked: false, - numEvents: 3, }, '201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false }, '202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 }, @@ -615,7 +612,6 @@ describe('generate() v4', () => { endTime: 1200, duration: 300, isFirstLinked: false, - numEvents: 3, }, '301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false }, '302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 }, diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 9a50f81ab0..2331e13d8d 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -169,7 +169,6 @@ export function generate( processedEntry.endTime = blockEndTime; processedEntry.isFirstLinked = isFirstLinked; processedEntry.events = blockEvents; - processedEntry.numEvents = blockEvents.length; } } @@ -416,7 +415,6 @@ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { eventId: entry.parent, patch: { events: parentEvents, - numEvents: parentEvents.length, }, }); } diff --git a/apps/server/src/user/styles/override.css b/apps/server/src/user/styles/override.css index 4ee7275ab0..19af316685 100644 --- a/apps/server/src/user/styles/override.css +++ b/apps/server/src/user/styles/override.css @@ -1,3 +1,4 @@ + /** * This CSS file allows user customisation of the UI * We expose some CSS properties to facilitate this (see below in :root) diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 02a6cd30b0..534ccc1fbc 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -180,8 +180,7 @@ "startTime": null, "endTime": null, "duration": 0, - "isFirstLinked": false, - "numEvents": 0 + "isFirstLinked": false }, "1c420": { "type": "event", diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index 02a6cd30b0..ee0c3ed556 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -180,8 +180,7 @@ "startTime": null, "endTime": null, "duration": 0, - "isFirstLinked": false, - "numEvents": 0 + "isFirstLinked": false }, "1c420": { "type": "event", @@ -341,8 +340,7 @@ "startTime": null, "endTime": null, "duration": 0, - "isFirstLinked": false, - "numEvents": 0 + "isFirstLinked": false }, "503c4": { "type": "event", diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 0e9bbd2e7e..3ed7799666 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -33,7 +33,6 @@ export type OntimeBlock = OntimeBaseEvent & { endTime: MaybeNumber; // calculated at runtime duration: number; // calculated at runtime isFirstLinked: boolean; // calculated at runtime, whether the first event is linked - numEvents: number; // calculated at runtime }; export type OntimeEvent = OntimeBaseEvent & { From 256755bf02bdb4e645bdbb546a1c3296ba745d5a Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Fri, 23 May 2025 13:06:48 +0200 Subject: [PATCH 44/49] refactor: small ux improvements - rename dissolve > ungroup - prevent ondrag when clicking - add untitled as block title fallback - move block action to context menu --- apps/client/src/common/api/rundown.ts | 4 +- .../client/src/common/hooks/useEntryAction.ts | 14 ++--- apps/client/src/features/rundown/Rundown.tsx | 2 +- .../rundown/block-block/BlockBlock.tsx | 54 +++++++++---------- .../api-data/rundown/rundown.controller.ts | 6 +-- .../src/api-data/rundown/rundown.router.ts | 4 +- .../rundown-service/RundownService.ts | 4 +- .../__tests__/rundownCache.test.ts | 6 +-- .../services/rundown-service/rundownCache.ts | 6 +-- 9 files changed, 47 insertions(+), 53 deletions(-) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 1a6e634193..80c497ca8f 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -97,8 +97,8 @@ export async function postCloneEntry(entryId: EntryId): Promise> { - return axios.post(`${rundownPath}/dissolve/${blockId}`); +export async function requestUngroup(blockId: EntryId): Promise> { + return axios.post(`${rundownPath}/ungroup/${blockId}`); } /** diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index 85d28b3455..0051056ca1 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -26,9 +26,9 @@ import { ReorderEntry, requestApplyDelay, requestDeleteAll, - requestDissolveBlock, requestEventSwap, requestGroupEntries, + requestUngroup, SwapEntry, } from '../api/rundown'; import { logAxiosError } from '../api/utils'; @@ -544,8 +544,8 @@ export const useEntryActions = () => { * Calls mutation to dissolve a block * @private */ - const _dissolveBlockMutation = useMutation({ - mutationFn: requestDissolveBlock, + const _ungroupMutation = useMutation({ + mutationFn: requestUngroup, onSuccess: (response) => { if (!response.data) return; @@ -565,15 +565,15 @@ export const useEntryActions = () => { /** * Deletes a block and moves its events to the top level */ - const dissolveBlock = useCallback( + const ungroup = useCallback( async (blockId: EntryId) => { try { - await _dissolveBlockMutation.mutateAsync(blockId); + await _ungroupMutation.mutateAsync(blockId); } catch (error) { logAxiosError('Error dissolving block', error); } }, - [_dissolveBlockMutation], + [_ungroupMutation], ); /** @@ -762,7 +762,7 @@ export const useEntryActions = () => { clone, deleteEntry, deleteAllEntries, - dissolveBlock, + ungroup, getEntryById, groupEntries, reorderEntry, diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 7043534560..6a4e1cc739 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -78,7 +78,7 @@ export default function Rundown({ data }: RundownProps) { useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run }); // DND KIT - const sensors = useSensors(useSensor(PointerSensor)); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } })); const deleteAtCursor = useCallback( (cursor: string | null) => { diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index 40f88d5a7f..7690cdef8a 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -3,16 +3,16 @@ import { IoChevronDown, IoChevronUp, IoDuplicateOutline, - IoEllipsisHorizontal, IoFolderOpenOutline, IoReorderTwo, IoTrash, } from 'react-icons/io5'; -import { IconButton, Menu, MenuButton, MenuItem, MenuList, Portal } from '@chakra-ui/react'; +import { IconButton } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { EntryId, OntimeBlock } from 'ontime-types'; +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'; @@ -31,7 +31,27 @@ interface BlockBlockProps { export default function BlockBlock(props: BlockBlockProps) { const { data, hasCursor, collapsed, onCollapse } = props; const handleRef = useRef(null); - const { clone, dissolveBlock, deleteEntry } = useEntryActions(); + 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, @@ -60,12 +80,11 @@ export default function BlockBlock(props: BlockBlockProps) { cursor: isOver ? (isValidDrop ? 'grabbing' : 'no-drop') : 'default', }; - const hasChildren = data.events.length > 0; - return (
- - } - color='#e2e2e2' // $gray-200 - variant='ontime-ghosted' - size='sm' - /> - - - } onClick={() => clone(data.id)}> - Clone Block - - {hasChildren && ( - } onClick={() => dissolveBlock(data.id)}> - Dissolve Block - - )} - } onClick={() => deleteEntry([data.id])}> - Delete Block - - - - onCollapse(!collapsed, data.id)} diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index ac743bbe31..ce84fdfcf6 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -11,7 +11,7 @@ import { deleteAllEntries, deleteEvent, editEvent, - dissolveBlock, + ungroupEntries, groupEntries, reorderEntry, swapEvents, @@ -139,9 +139,9 @@ export async function rundownCloneEntry(req: Request, res: Response) { +export async function rundownUngroupEntries(req: Request, res: Response) { try { - const newRundown = await dissolveBlock(req.params.entryId); + const newRundown = await ungroupEntries(req.params.entryId); res.status(200).send(newRundown); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 44e1bcc97a..e2cb3ccf92 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -7,7 +7,7 @@ import { rundownBatchPut, rundownCloneEntry, rundownDelete, - rundownDissolveBlock, + rundownUngroupEntries, rundownGetAll, rundownGetById, rundownGetCurrent, @@ -41,7 +41,7 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder); router.patch('/swap', rundownSwapValidator, rundownSwap); router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay); router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); -router.post('/dissolve/:entryId', paramsMustHaveEntryId, rundownDissolveBlock); +router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries); router.post('/group', rundownArrayOfIds, rundownAddToBlock); router.delete('/', rundownArrayOfIds, deletesEventById); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 413571b5c9..2bc5b90b00 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -244,8 +244,8 @@ export async function cloneEntry(entryId: EntryId) { /** * Deletes a block from the rundown and moves all its children to the top level */ -export async function dissolveBlock(blockId: EntryId) { - const scopedMutation = cache.mutateCache(cache.dissolveBlock); +export async function ungroupEntries(blockId: EntryId) { + const scopedMutation = cache.mutateCache(cache.ungroup); const { newRundown } = await scopedMutation({ blockId }); // notify runtime that rundown has changed diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index 44d817a0a8..cc78148f99 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -15,7 +15,7 @@ import { editCustomField, removeCustomField, customFieldChangelog, - dissolveBlock, + ungroup, groupEntries, clone, } from '../rundownCache.js'; @@ -870,7 +870,7 @@ describe('clone() mutation', () => { }); }); -describe('dissolveBlock() mutation', () => { +describe('ungroup() mutation', () => { it('should correctly dissolve a block into its events', () => { const rundown = makeRundown({ order: ['1', '2'], @@ -883,7 +883,7 @@ describe('dissolveBlock() mutation', () => { }, }); - const { newRundown } = dissolveBlock({ + const { newRundown } = ungroup({ rundown, blockId: '2', }); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 2331e13d8d..439ee2feef 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -594,7 +594,7 @@ export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn { const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1; newBlock.events = nestedIds; - newBlock.title = `${entry.title} (copy)`; + newBlock.title = `${entry.title || 'Untitled'} (copy)`; rundown.entries[newBlock.id] = newBlock; rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order); @@ -606,13 +606,13 @@ export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn { } } -type DissolveBlockArgs = MutationParams<{ blockId: EntryId }>; +type UngroupArgs = MutationParams<{ blockId: EntryId }>; /** * Deletes a block and moves all its children to the top level order * Mutates the given rundown * @throws if block ID not found */ -export function dissolveBlock({ rundown, blockId }: DissolveBlockArgs): MutatingReturn { +export function ungroup({ rundown, blockId }: UngroupArgs): MutatingReturn { const block = rundown.entries[blockId]; if (!isOntimeBlock(block)) { throw new Error('Block with ID not found'); From 9f27832b29ce3cbeb7b2d514dafc9233bf31ab6a Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 26 May 2025 20:13:56 +0200 Subject: [PATCH 45/49] refactor: refetch targets is enum --- apps/server/src/adapters/websocketAux.ts | 5 +++++ apps/server/src/api-data/report/report.service.ts | 4 ++-- apps/server/src/services/rundown-service/RundownService.ts | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) 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/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index c453d9509f..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,7 +58,7 @@ export function triggerReportEntry( report.set(eventId, { startedAt, endedAt: state.clock }); formattedReport = null; sendRefetch({ - target: 'REPORT', + target: RefetchTargets.Report, }); } } diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 2bc5b90b00..2b7b8e2f36 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -15,7 +15,7 @@ import { import { getCueCandidate } from 'ontime-utils'; import { delay as delayDef } from '../../models/eventsDefinition.js'; -import { sendRefetch } from '../../adapters/websocketAux.js'; +import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js'; import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js'; import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; @@ -334,7 +334,7 @@ function notifyChanges(options: NotifyChangesOptions) { if (options.external) { // advice socket subscribers of change const payload = { - target: 'RUNDOWN', + target: RefetchTargets.Rundown, changes: Array.isArray(options.timer) ? options.timer : undefined, reload: options.reload, revision: cache.getMetadata().revision, From 8b37f6cc99f97a8ca4ed6ba3de58e1828102fbd5 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Mon, 26 May 2025 21:59:03 +0200 Subject: [PATCH 46/49] refactor: order is single source of truth --- apps/client/src/features/rundown/Rundown.tsx | 29 +++++---- .../rundown/__tests__/rundown.utils.test.ts | 14 ++--- .../src/features/rundown/rundown.utils.ts | 61 ++++++++++--------- packages/utils/src/common/arrayUtils.test.ts | 8 ++- 4 files changed, 60 insertions(+), 52 deletions(-) diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 6a4e1cc739..27001a5bf3 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -42,7 +42,7 @@ import { cloneEvent } from '../../common/utils/clone'; import BlockBlock from './block-block/BlockBlock'; import BlockEnd from './block-block/BlockEnd'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; -import { makeRundownMetadata, makeSortableList } from './rundown.utils'; +import { getNextId, getPreviousId, makeRundownMetadata, makeSortableList } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -55,10 +55,10 @@ interface RundownProps { } export default function Rundown({ data }: RundownProps) { - const { order, flatOrder, entries, id } = data; + const { order, entries, id } = data; // we create a copy of the rundown with a data structured aligned with what dnd-kit needs const featureData = useRundownEditor(); - const [sortableData, setSortableData] = useState(() => makeSortableList(flatOrder, entries)); + const [sortableData, setSortableData] = useState(() => makeSortableList(order, entries)); const [collapsedGroups, setCollapsedGroups] = useSessionStorage({ // we ensure that this is unique to the rundown key: `rundown.${id}-editor-collapsed-groups`, @@ -192,15 +192,15 @@ export default function Rundown({ data }: RundownProps) { if (order.length < 2 || cursor == null) { return; } - const { index } = - direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); - if (index !== null) { - const offsetIndex = direction === 'up' ? index + 1 : index - 1; - reorderEntry(cursor, offsetIndex, index); + const destinationId = direction === 'up' ? getPreviousId(cursor, sortableData) : getNextId(cursor, sortableData); + if (direction === 'up' && destinationId === null) { + reorderEntry(cursor, cursor, 'before'); + } else if (destinationId !== null) { + reorderEntry(cursor, destinationId); } }, - [order, reorderEntry, entries], + [order.length, sortableData, reorderEntry], ); // shortcuts @@ -237,8 +237,8 @@ export default function Rundown({ data }: RundownProps) { // we copy the state from the store here // to workaround async updates on the drag mutations useEffect(() => { - setSortableData(makeSortableList(flatOrder, entries)); - }, [flatOrder, entries]); + setSortableData(makeSortableList(order, entries)); + }, [order, entries]); // in run mode, we follow selection useEffect(() => { @@ -287,14 +287,13 @@ export default function Rundown({ data }: RundownProps) { if (over?.id) { if (active.id !== over?.id) { - const fromIndex = active.data.current?.sortable.index; - const toIndex = over.data.current?.sortable.index; - // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates setSortableData((currentEntries) => { + const fromIndex = active.data.current?.sortable.index; + const toIndex = over.data.current?.sortable.index; return reorderArray(currentEntries, fromIndex, toIndex); }); - reorderEntry(String(active.id), fromIndex, toIndex); + reorderEntry(active.id as string, over.id as string, 'before'); } } }; diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index e91bd01495..99998e1bd2 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -286,7 +286,7 @@ describe('makeRundownMetadata()', () => { describe('makeSortableList()', () => { it('generates a list with block ends', () => { - const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4']; + 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, @@ -296,8 +296,8 @@ describe('makeSortableList()', () => { 'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock, }; - const sortableList = makeSortableList(flatOrder, entries); - expect(sortableList).toEqual([ + const sortableList = makeSortableList(order, entries); + expect(sortableList).toStrictEqual([ 'block-1', '11', 'end-block-1', @@ -311,25 +311,25 @@ describe('makeSortableList()', () => { }); it('closes dangling blocks', () => { - const flatOrder = ['block', '11', '12']; + 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(flatOrder, entries); + 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 flatOrder = ['block-1', 'block-2']; + 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(flatOrder, entries); + const sortableList = makeSortableList(order, entries); expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']); }); }); diff --git a/apps/client/src/features/rundown/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index afe66fb5c1..90815173e9 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -126,44 +126,29 @@ function processEntry( * 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(flatOrder: EntryId[], entries: RundownEntries): EntryId[] { - const entryIds: EntryId[] = []; - let lastSeenBlock: MaybeString = null; +export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] { + const flatIds: EntryId[] = []; - for (let i = 0; i < flatOrder.length; i++) { - const entry = entries[flatOrder[i]]; + for (let i = 0; i < order.length; i++) { + const entry = entries[order[i]]; if (!entry) { continue; } if (isOntimeBlock(entry)) { - // close any previous blocks - if (lastSeenBlock !== null) { - entryIds.push(`end-${lastSeenBlock}`); - } - lastSeenBlock = entry.id; - } - - if (isOntimeEvent(entry)) { - // Close the previous block if the parent changes - if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) { - entryIds.push(`end-${lastSeenBlock}`); - } - lastSeenBlock = entry.parent; + // 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); } - - entryIds.push(entry.id); - } - - // double check that we close any dangling blocks - // - if the last element is a block - // - if a rundown only has a top level block - if (lastSeenBlock !== null) { - entryIds.push(`end-${lastSeenBlock}`); } - - return entryIds; + return flatIds; } /** @@ -178,3 +163,21 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu // we can swap places with other blocks return targetType == 'block'; } + +export function getNextId(entryId: EntryId, sortableData: EntryId[]): MaybeString { + 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]; +} + +export function getPreviousId(entryId: EntryId, sortableData: EntryId[]): MaybeString { + 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/packages/utils/src/common/arrayUtils.test.ts b/packages/utils/src/common/arrayUtils.test.ts index 8f91775deb..49c2442d92 100644 --- a/packages/utils/src/common/arrayUtils.test.ts +++ b/packages/utils/src/common/arrayUtils.test.ts @@ -88,12 +88,18 @@ describe('deleteAtIndex', () => { }); describe('reorderArray', () => { - it('should reorder an item in the array', () => { + it('should reorder an item in the array (up)', () => { const array = ['a', 'b', 'c', 'd']; const result = reorderArray(array, 1, 3); expect(result).toEqual(['a', 'c', 'd', 'b']); }); + it('should reorder an item in the array (down)', () => { + const array = ['a', 'b', 'c', 'd']; + const result = reorderArray(array, 3, 1); + expect(result).toEqual(['a', 'd', 'b', 'c']); + }); + it('should return the original array if fromIndex and toIndex are the same', () => { const array = ['a', 'b', 'c']; const result = reorderArray(array, 1, 1); From b919f00501a01ba30442db27dc1e4f878f79fbfb Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 27 May 2025 15:38:10 +0200 Subject: [PATCH 47/49] chore: simplify URLs --- apps/client/src/common/api/external.ts | 2 +- apps/client/src/common/api/report.ts | 2 +- apps/client/src/common/api/rundown.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/client/src/common/api/external.ts b/apps/client/src/common/api/external.ts index e84e49a150..9c2c0ba536 100644 --- a/apps/client/src/common/api/external.ts +++ b/apps/client/src/common/api/external.ts @@ -11,7 +11,7 @@ export type HasUpdate = { * HTTP request to get the latest version and url from github */ export async function getLatestVersion(): Promise { - const res = await axios.get(`${apiRepoLatest}`); + const res = await axios.get(apiRepoLatest); return { url: res.data.html_url as string, version: res.data.tag_name as string, diff --git a/apps/client/src/common/api/report.ts b/apps/client/src/common/api/report.ts index 2ea67e94a1..619d946e62 100644 --- a/apps/client/src/common/api/report.ts +++ b/apps/client/src/common/api/report.ts @@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`; * HTTP request to fetch all reports */ export async function fetchReport(): Promise { - const res = await axios.get(`${reportUrl}/`); + const res = await axios.get(reportUrl); return res.data; } diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 80c497ca8f..1ca6b9f4cb 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -17,7 +17,7 @@ const rundownPath = `${apiEntryUrl}/rundown`; * HTTP request to fetch a list of existing rundowns */ export async function fetchProjectRundownList(): Promise { - const res = await axios.get(`${rundownPath}/`); + const res = await axios.get(rundownPath); return res.data; } From a89f5b93da16ba39f030e287b16b87d347c7d6b3 Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Tue, 27 May 2025 15:38:23 +0200 Subject: [PATCH 48/49] refactor: improve reorder logic --- apps/client/src/common/api/rundown.ts | 6 +- .../client/src/common/hooks/useEntryAction.ts | 81 ++---- apps/client/src/features/rundown/Rundown.tsx | 61 ++-- .../rundown/__tests__/rundown.utils.test.ts | 68 ++++- .../src/features/rundown/rundown.utils.ts | 86 +++++- .../CuesheetTableMenuActions.tsx | 10 +- .../api-data/rundown/rundown.controller.ts | 16 -- .../src/api-data/rundown/rundown.router.ts | 18 +- .../api-data/rundown/rundown.validation.ts | 6 +- .../rundown-service/RundownService.ts | 11 +- .../__tests__/rundownCache.test.ts | 271 +++++++++++++++++- .../services/rundown-service/rundownCache.ts | 71 +++-- 12 files changed, 559 insertions(+), 146 deletions(-) diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 1ca6b9f4cb..f1dfec9960 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -56,9 +56,9 @@ export async function putBatchEditEvents(data: BatchEditEntry): Promise { */ const _reorderEntryMutation = useMutation({ mutationFn: patchReorderEntry, - // we optimistically update here - onMutate: async (data) => { - // cancel ongoing queries - await queryClient.cancelQueries({ queryKey: RUNDOWN }); - - // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); - - if (previousData) { - // optimistically update object - const newOrder = reorderArray(previousData.order, data.from, data.to); - queryClient.setQueryData(RUNDOWN, { - id: previousData.id, - title: previousData.title, - order: newOrder, - flatOrder: previousData.flatOrder, - entries: previousData.entries, - revision: -1, - }); - } - - // Return a context with the previous and new events - return { previousData }; - }, - - // Mutation fails, rollback undoes optimist update - onError: (_error, _data, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); - }, - - // Mutation finished, we update the rundown with the response - onSuccess: (response) => { - if (!response.data) return; - - const { id, title, order, flatOrder, entries, revision } = response.data; - queryClient.setQueryData(RUNDOWN, { - id, - title, - order, - flatOrder, - entries, - revision, - }); - }, - // Mutation finished, failed or successful // Fetch anyway, just to be sure onSettled: () => { @@ -674,12 +630,12 @@ export const useEntryActions = () => { * Reorders a given entry */ const reorderEntry = useCallback( - async (entryId: string, from: number, to: number) => { + async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => { try { const reorderObject: ReorderEntry = { - eventId: entryId, - from, - to, + entryId, + destinationId, + order, }; await _reorderEntryMutation.mutateAsync(reorderObject); } catch (error) { @@ -689,6 +645,30 @@ export const useEntryActions = () => { [_reorderEntryMutation], ); + const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => { + const cachedRundown = queryClient.getQueryData(RUNDOWN); + if (!cachedRundown?.order) { + return; + } + const { destinationId, order } = + direction === 'up' + ? moveUp(entryId, cachedRundown.order, cachedRundown.entries) + : moveDown(entryId, cachedRundown.order, cachedRundown.entries); + + if (destinationId) { + try { + const reorderObject: ReorderEntry = { + entryId, + destinationId, + order: order as 'before' | 'after' | 'insert', + }; + await _reorderEntryMutation.mutateAsync(reorderObject); + } catch (error) { + logAxiosError('Error re-ordering event', error); + } + } + }, []); + /** * Calls mutation to swap events * @private @@ -765,6 +745,7 @@ export const useEntryActions = () => { ungroup, getEntryById, groupEntries, + move, reorderEntry, swapEvents, updateEntry, diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 27001a5bf3..d21747cec8 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -42,7 +42,7 @@ import { cloneEvent } from '../../common/utils/clone'; import BlockBlock from './block-block/BlockBlock'; import BlockEnd from './block-block/BlockEnd'; import QuickAddBlock from './quick-add-block/QuickAddBlock'; -import { getNextId, getPreviousId, makeRundownMetadata, makeSortableList } from './rundown.utils'; +import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils'; import RundownEmpty from './RundownEmpty'; import { useEventSelection } from './useEventSelection'; @@ -188,19 +188,26 @@ export default function Rundown({ data }: RundownProps) { ); const moveEntry = useCallback( - (cursor: string | null, direction: 'up' | 'down') => { - if (order.length < 2 || cursor == null) { + (cursor: EntryId | null, direction: 'up' | 'down') => { + if (sortableData.length < 2 || cursor == null) { + return; + } + + const { destinationId, order, isBlock } = + direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries); + + if (!destinationId) { return; } - const destinationId = direction === 'up' ? getPreviousId(cursor, sortableData) : getNextId(cursor, sortableData); - if (direction === 'up' && destinationId === null) { - reorderEntry(cursor, cursor, 'before'); - } else if (destinationId !== null) { - reorderEntry(cursor, destinationId); + // if we are moving into a block, we need to make sure it is expanded + if (isBlock) { + handleCollapseGroup(false, destinationId); } + + reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert'); }, - [order.length, sortableData, reorderEntry], + [sortableData, reorderEntry], ); // shortcuts @@ -285,17 +292,33 @@ export default function Rundown({ data }: RundownProps) { const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; - if (over?.id) { - if (active.id !== over?.id) { - // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates - setSortableData((currentEntries) => { - const fromIndex = active.data.current?.sortable.index; - const toIndex = over.data.current?.sortable.index; - return reorderArray(currentEntries, fromIndex, toIndex); - }); - reorderEntry(active.id as string, over.id as string, 'before'); - } + if (!over?.id || active.id === over.id) { + return; } + + const fromIndex = active.data.current?.sortable.index; + const toIndex = over.data.current?.sortable.index; + + // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates + setSortableData((currentEntries) => { + return reorderArray(currentEntries, fromIndex, toIndex); + }); + + let destinationId = over.id as EntryId; + let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before'; + + /** + * We need to specially handle the end blocks + * Dragging before and end block will add the entry to the end of the block + * Dragging after an end block will add the event after the block itself + */ + if (destinationId.startsWith('end-')) { + destinationId = destinationId.replace('end-', ''); + // if we are moving before the end, we use the insert operation + order = 'insert'; + } + + reorderEntry(active.id as EntryId, destinationId, order); }; /** diff --git a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts index 99998e1bd2..d94ddff85b 100644 --- a/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts +++ b/apps/client/src/features/rundown/__tests__/rundown.utils.test.ts @@ -1,6 +1,6 @@ -import { OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; +import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types'; -import { makeRundownMetadata, makeSortableList } from '../rundown.utils'; +import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils'; describe('makeRundownMetadata()', () => { it('processes nested rundown data', () => { @@ -333,3 +333,67 @@ describe('makeSortableList()', () => { 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/rundown.utils.ts b/apps/client/src/features/rundown/rundown.utils.ts index 90815173e9..58159c6d16 100644 --- a/apps/client/src/features/rundown/rundown.utils.ts +++ b/apps/client/src/features/rundown/rundown.utils.ts @@ -164,7 +164,85 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu return targetType == 'block'; } -export function getNextId(entryId: EntryId, sortableData: EntryId[]): MaybeString { +/** + * 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 @@ -173,7 +251,11 @@ export function getNextId(entryId: EntryId, sortableData: EntryId[]): MaybeStrin return sortableData[currentIndex + 1]; } -export function getPreviousId(entryId: EntryId, sortableData: EntryId[]): MaybeString { +/** + * 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 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 3e924282bb..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 @@ -13,7 +13,7 @@ interface CuesheetTableMenuActionsProps { export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) { const { eventId, entryIndex, showModal } = props; - const { addEntry, getEntryById, reorderEntry, deleteEntry } = useEntryActions(); + const { addEntry, getEntryById, move, deleteEntry } = useEntryActions(); const handleCloneEvent = () => { const currentEvent = getEntryById(eventId); @@ -45,14 +45,10 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions Clone event - } - onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)} - > + } onClick={() => move(eventId, 'up')}> Move up - } onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}> + } onClick={() => move(eventId, 'down')}> Move down } onClick={() => deleteEntry([eventId])}> diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index ce84fdfcf6..0f173d3854 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -13,7 +13,6 @@ import { editEvent, ungroupEntries, groupEntries, - reorderEntry, swapEvents, cloneEntry, } from '../../services/rundown-service/RundownService.js'; @@ -89,21 +88,6 @@ export async function rundownBatchPut(req: Request, res: Response) { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const { eventId, from, to } = req.body; - const newRundown = await reorderEntry(eventId, from, to); - res.status(200).send(newRundown); - } 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; diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index e2cb3ccf92..e1c4451c5c 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -1,5 +1,11 @@ +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, @@ -13,7 +19,6 @@ import { rundownGetCurrent, rundownPost, rundownPut, - rundownReorder, rundownSwap, } from './rundown.controller.js'; import { @@ -37,7 +42,16 @@ 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/:entryId', paramsMustHaveEntryId, rundownApplyDelay); router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry); diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index eb018d8afc..6a396f21da 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -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); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 2b7b8e2f36..2514bac715 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -187,13 +187,14 @@ export async function batchEditEvents(ids: string[], data: Partial) /** * reorders a given entry - * @param {string} eventId - ID of event from, for sanity check - * @param {number} from - index of event from - * @param {number} to - index of event to */ -export async function reorderEntry(eventId: EntryId, from: number, to: number): Promise { +export async function reorderEntry( + entryId: EntryId, + destinationId: EntryId, + order: 'before' | 'after' | 'insert', +): Promise { const scopedMutation = cache.mutateCache(cache.reorder); - const { changeList, newRundown } = await scopedMutation({ eventId, from, to }); + const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order }); // notify runtime that rundown has changed updateRuntimeOnChange(); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index cc78148f99..840f922b0e 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -783,31 +783,276 @@ describe('batchEdit() mutation', () => { }); describe('reorder() mutation', () => { - it('should correctly reorder two events', () => { + it('moves an event into a block', () => { const rundown = makeRundown({ order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeEvent({ id: '2', parent: null }), + '3': makeOntimeEvent({ id: '3', parent: null }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '3', + destinationId: '1', + order: 'insert', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '3', '2']); + // expect(changeList).toStrictEqual(['1', '3', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: ['3'], + }); + expect(rundown.entries['3']).toMatchObject({ + parent: '1', + }); + }); + + it('adds an event into a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '2', + destinationId: '11', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11']); + // expect(changeList).toStrictEqual(['1', '2', '11']); + expect(rundown.entries['1']).toMatchObject({ + events: ['2', '11'], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event after another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], entries: { - '1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }), - '2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }), - '3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }), + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), }, }); // move first event to the end + const { newRundown } = reorder({ + rundown: rundown, + entryId: '1', + destinationId: '2', + order: 'after', + }); + + expect(newRundown.order).toStrictEqual(['2', '1', '3']); + // expect(newRundown.flatOrder).toStrictEqual(['2', '3', '1']); + // expect(changeList).toStrictEqual(['2', '3', '1']); + }); + + it('moves an event before another', () => { + const rundown = makeRundown({ + order: ['1', '2', '3'], + flatOrder: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + + // move last event to the beginning + const { newRundown } = reorder({ + rundown: rundown, + entryId: '3', + destinationId: '1', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['3', '1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['3', '1', '2']); + // expect(changeList).toStrictEqual(['3', '1', '2']); + }); + + it('moves an event out of a block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeEvent({ id: '2', parent: null }), + }, + }); + const { newRundown, changeList } = reorder({ rundown: rundown, - eventId: rundown.order[0], - from: 0, - to: rundown.order.length - 1, + entryId: '11', + destinationId: '2', + order: 'before', }); - expect(newRundown.order).toStrictEqual(['2', '3', '1']); - expect(newRundown.entries).toMatchObject({ - '2': { id: '2', cue: 'data2', revision: 1 }, - '3': { id: '3', cue: 'data3', revision: 1 }, - '1': { id: '1', cue: 'data1', revision: 1 }, + expect(newRundown.order).toStrictEqual(['1', '11', '2']); + expect(newRundown.flatOrder).toStrictEqual(['1', '11', '2']); + expect(changeList).toStrictEqual(['1', '11', '2']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event between blocks', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '11', + destinationId: '22', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['11', '22'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '2', + }); + }); + + it('moves an event into an empty block', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: [] }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '22', + destinationId: '1', + order: 'insert', + }); + + expect(newRundown.order).toStrictEqual(['1', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '1', + }); + }); + + it('moves an event out of a block (up)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '22', + destinationId: '2', + order: 'before', + }); + + expect(newRundown.order).toStrictEqual(['1', '22', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: ['11'], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: '1', + }); + expect(rundown.entries['2']).toMatchObject({ + events: [], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: null, + }); + }); + + it('moves an event out of a block (down)', () => { + const rundown = makeRundown({ + order: ['1', '2'], + flatOrder: ['1', '11', '2', '22'], + entries: { + '1': makeOntimeBlock({ id: '1', events: ['11'] }), + '11': makeOntimeEvent({ id: '11', parent: '1' }), + '2': makeOntimeBlock({ id: '2', events: ['22'] }), + '22': makeOntimeEvent({ id: '22', parent: '2' }), + }, + }); + + const { newRundown } = reorder({ + rundown: rundown, + entryId: '11', + destinationId: '1', + order: 'after', + }); + + expect(newRundown.order).toStrictEqual(['1', '11', '2']); + // expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']); + // expect(changeList).toStrictEqual(['1', '2', '11', '22']); + expect(rundown.entries['1']).toMatchObject({ + events: [], + }); + expect(rundown.entries['11']).toMatchObject({ + parent: null, + }); + expect(rundown.entries['2']).toMatchObject({ + events: ['22'], + }); + expect(rundown.entries['22']).toMatchObject({ + parent: '2', }); - expect(changeList).toStrictEqual(['2', '3', '1']); }); }); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 439ee2feef..23f090ae02 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -13,14 +13,7 @@ import { RundownEntries, OntimeDelay, } from 'ontime-types'; -import { - generateId, - insertAtIndex, - reorderArray, - swapEventData, - customFieldLabelToKey, - mergeAtIndex, -} from 'ontime-utils'; +import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js'; @@ -518,31 +511,61 @@ export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): Mutating return { newRundown: rundown, didMutate: true }; } -type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>; +type ReorderArgs = MutationParams<{ + entryId: EntryId; + destinationId: EntryId; + order: 'before' | 'after' | 'insert'; +}>; /** - * Reorder two entries + * Moves an event to a new position in the rundown + * Handles moving across root orders (a block order and top level order) + * @throws if entryId or destinationId not found + * @throws if trying to insert an event into a block inside another block */ -export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required { - const eventFrom = rundown.entries[eventId]; - if (!eventFrom) { +export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required { + const eventFrom = rundown.entries[entryId]; + const eventTo = rundown.entries[destinationId]; + + if (!eventFrom || !eventTo) { throw new Error('Event not found'); } - rundown.order = reorderArray(rundown.order, from, to); - - // increment revision of all events in between - for (let i = from; i <= to; i++) { - const eventId = rundown.order[i]; - const entry = rundown.entries[eventId]; - if (isOntimeEvent(entry) || isOntimeBlock(entry)) { - entry.revision += 1; + const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null; + const toParent = (() => { + if (isOntimeBlock(eventTo)) { + if (order === 'insert') { + return eventTo.id; + } + return null; } + return eventTo.parent ?? null; + })(); + + if (!isOntimeBlock(eventFrom)) { + eventFrom.parent = toParent; } - // all events from the first one, need to be updated - const changeList = rundown.order.slice(Math.min(from, to), rundown.order.length); + const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events; + const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events; + + const fromIndex = sourceArray.indexOf(entryId); + const toIndex = (() => { + const baseIndex = destinationArray.indexOf(destinationId); + if (order === 'before') return baseIndex; + // only add one if we are moving down + if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1); + // for insert we add in the end of the array + return destinationArray.length; + })(); + + // Remove from source array + sourceArray.splice(fromIndex, 1); + // Insert into destination array + destinationArray.splice(toIndex, 0, entryId); + + // changelist is derived from the flat order + const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length); - setIsStale(); return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true }; } From 6348defd42b98f757e20079e16f8119909345823 Mon Sep 17 00:00:00 2001 From: arc-alex Date: Wed, 23 Apr 2025 08:27:52 +0200 Subject: [PATCH 49/49] test level db --- apps/server/package.json | 1 + apps/server/src/app.ts | 56 +++++++++---- .../src/classes/data-provider/DataProvider.ts | 71 ++++++++++++----- .../classes/data-provider/levelDb.utils.ts | 24 ++++++ pnpm-lock.yaml | 79 +++++++++++++++++++ 5 files changed, 196 insertions(+), 35 deletions(-) create mode 100644 apps/server/src/classes/data-provider/levelDb.utils.ts diff --git a/apps/server/package.json b/apps/server/package.json index 6f50b0606f..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", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 954ab6c32f..48f43f385a 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -243,47 +243,69 @@ export const startIntegrations = async () => { * @param {number} exitCode * @return {Promise} */ -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/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 4793a123dd..fbf7e6a3b4 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -8,6 +8,7 @@ import { AutomationSettings, Rundown, ProjectRundowns, + LogOrigin, } from 'ontime-types'; import type { Low } from 'lowdb'; @@ -23,6 +24,18 @@ 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 */ @@ -31,6 +44,19 @@ export async function initPersistence(filePath: string, fallbackData: DatabaseMo 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(); @@ -60,6 +86,7 @@ export function getDataProvider() { setAutomation, getRundown, mergeIntoData, + shutdown, }; } @@ -68,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 { @@ -97,8 +124,8 @@ async function mergeRundown( return { rundowns: db.data.rundowns, customFields: db.data.customFields }; } -function getCustomFields(): Readonly { - return db.data.customFields; +function getCustomFields(): CustomFields { + return main_db.getSync('customFields') as CustomFields; } async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { @@ -107,8 +134,8 @@ async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise return db.data.rundowns[rundownKey]; } -function getSettings(): Readonly { - return db.data.settings; +function getSettings(): Settings { + return main_db.getSync('settings') as Settings; } async function setSettings(newData: Settings): ReadonlyPromise { @@ -117,8 +144,8 @@ async function setSettings(newData: Settings): ReadonlyPromise { return db.data.settings; } -function getUrlPresets(): Readonly { - return db.data.urlPresets; +function getUrlPresets(): URLPreset[] { + return main_db.getSync('urlPresets') as URLPreset[]; } async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise { @@ -127,8 +154,8 @@ async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise return db.data.urlPresets; } -function getViewSettings(): Readonly { - return db.data.viewSettings; +function getViewSettings(): ViewSettings { + return main_db.getSync('viewSettings'); } async function setViewSettings(newData: ViewSettings): ReadonlyPromise { @@ -137,8 +164,10 @@ async function setViewSettings(newData: ViewSettings): ReadonlyPromise { - return db.data.automation; +function getAutomation(): AutomationSettings { + const automation = main_db.getSync('automation'); + if (!automation) throw new Error('Failed to load automation from db'); + return automation; } async function setAutomation(newData: AutomationSettings): ReadonlyPromise { @@ -147,9 +176,10 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise { - const firstRundown = Object.keys(db.data.rundowns)[0]; - return db.data.rundowns[firstRundown]; +function getRundown(): Rundown { + const rundown = rundown_db.getSync('default'); + if (!rundown) throw new Error('Failed to load rundown from db'); + return rundown; } async function mergeIntoData(newData: Partial): ReadonlyPromise { @@ -173,3 +203,8 @@ async function persist() { if (isTest) return; await db.write(); } + +async function shutdown() { + logger.info(LogOrigin.Server, 'Closing DB'); + await main_db.close(); +} diff --git a/apps/server/src/classes/data-provider/levelDb.utils.ts b/apps/server/src/classes/data-provider/levelDb.utils.ts new file mode 100644 index 0000000000..0e829f305d --- /dev/null +++ b/apps/server/src/classes/data-provider/levelDb.utils.ts @@ -0,0 +1,24 @@ +export async function batchPutObject(obj: object, db) { + await db.batch( + Object.entries(obj).map(([key, value]) => { + return value === null ? { type: 'del', key } : { type: 'put', key, value }; + }), + ); +} + +// await projectDb.batch([ +// { type: 'put', key: 'title', value: project.title }, +// { type: 'put', key: 'description', value: project.description }, +// { type: 'put', key: 'publicUrl', value: project.publicUrl }, +// { type: 'put', key: 'publicInfo', value: project.publicInfo }, +// { type: 'put', key: 'backstageUrl', value: project.backstageUrl }, +// { type: 'put', key: 'backstageInfo', value: project.backstageInfo }, +// project.backstageInfo +// ? { type: 'put', key: 'projectLogo', value: project.projectLogo } +// : { type: 'del', key: 'projectLogo' }, +// ]); + +// // await levelDb.put('project', project); + +// console.log('level',levelDb.getSync('project')); +// console.log('project',projectDb.getSync('title')); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ad6219e08..2e5b77f020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: '@googleapis/sheets': specifier: ^5.0.5 version: 5.0.5 + classic-level: + specifier: ^3.0.0 + version: 3.0.0 cookie: specifier: ^1.0.2 version: 1.0.2 @@ -2272,6 +2275,10 @@ packages: resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} deprecated: Use your platform's native atob() and btoa() methods instead + abstract-level@3.1.0: + resolution: {integrity: sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw==} + engines: {node: '>=18'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2488,6 +2495,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builder-util-runtime@9.2.4: resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==} engines: {node: '>=12.0.0'} @@ -2584,6 +2594,10 @@ packages: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} + classic-level@3.0.0: + resolution: {integrity: sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==} + engines: {node: '>=18'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -3570,6 +3584,10 @@ packages: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} engines: {node: '>= 0.4'} + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} @@ -3738,6 +3756,14 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + level-supports@6.2.0: + resolution: {integrity: sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==} + engines: {node: '>=16'} + + level-transcoder@1.0.1: + resolution: {integrity: sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==} + engines: {node: '>=12'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3821,6 +3847,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + maybe-combine-errors@1.0.0: + resolution: {integrity: sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==} + engines: {node: '>=10'} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -3917,6 +3947,10 @@ packages: engines: {node: '>=10'} hasBin: true + module-error@1.0.2: + resolution: {integrity: sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==} + engines: {node: '>=10'} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -3937,6 +3971,9 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-macros@2.2.2: + resolution: {integrity: sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3959,6 +3996,10 @@ packages: encoding: optional: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.14: resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} @@ -7115,6 +7156,15 @@ snapshots: abab@2.0.6: optional: true + abstract-level@3.1.0: + dependencies: + buffer: 6.0.3 + is-buffer: 2.0.5 + level-supports: 6.2.0 + level-transcoder: 1.0.1 + maybe-combine-errors: 1.0.0 + module-error: 1.0.2 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -7396,6 +7446,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builder-util-runtime@9.2.4: dependencies: debug: 4.3.7 @@ -7526,6 +7581,13 @@ snapshots: ci-info@3.9.0: {} + classic-level@3.0.0: + dependencies: + abstract-level: 3.1.0 + module-error: 1.0.2 + napi-macros: 2.2.2 + node-gyp-build: 4.8.4 + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -8784,6 +8846,8 @@ snapshots: call-bind: 1.0.2 has-tostringtag: 1.0.2 + is-buffer@2.0.5: {} + is-callable@1.2.7: {} is-ci@3.0.1: @@ -8970,6 +9034,13 @@ snapshots: dependencies: readable-stream: 2.3.8 + level-supports@6.2.0: {} + + level-transcoder@1.0.1: + dependencies: + buffer: 6.0.3 + module-error: 1.0.2 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -9040,6 +9111,8 @@ snapshots: math-intrinsics@1.1.0: {} + maybe-combine-errors@1.0.0: {} + media-typer@0.3.0: {} merge-descriptors@1.0.3: {} @@ -9108,6 +9181,8 @@ snapshots: mkdirp@1.0.4: {} + module-error@1.0.2: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -9126,6 +9201,8 @@ snapshots: nanoid@5.0.7: {} + napi-macros@2.2.2: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -9142,6 +9219,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-gyp-build@4.8.4: {} + node-releases@2.0.14: {} normalize-path@3.0.0: {}