diff --git a/apps/client/src/common/api/eventsApi.ts b/apps/client/src/common/api/eventsApi.ts index dacf16c43..88c02a932 100644 --- a/apps/client/src/common/api/eventsApi.ts +++ b/apps/client/src/common/api/eventsApi.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { GetRundownCached, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import { OntimeEvent, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; import { rundownURL } from './apiConstants'; @@ -7,7 +7,7 @@ import { rundownURL } from './apiConstants'; * @description HTTP request to fetch all events * @return {Promise} */ -export async function fetchCachedRundown(): Promise { +export async function fetchCachedRundown(): Promise { const res = await axios.get(`${rundownURL}/cached`); return res.data; } @@ -26,7 +26,7 @@ export async function fetchRundown(): Promise { * @description HTTP request to post new event * @return {Promise} */ -export async function requestPostEvent(data: OntimeRundownEntry) { +export async function requestPostEvent(data: Partial) { return axios.post(rundownURL, data); } @@ -39,7 +39,7 @@ export async function requestPutEvent(data: Partial) { } type BatchEditEntry = { - data: Partial; + data: Partial; ids: string[]; }; diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index d213eddc0..5f05f074f 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,15 +1,16 @@ +import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { GetRundownCached } from 'ontime-types'; +import { NormalisedRundown, OntimeRundown, RundownCached } from 'ontime-types'; import { queryRefetchInterval } from '../../ontimeConfig'; import { RUNDOWN } from '../api/apiConstants'; import { fetchCachedRundown } from '../api/eventsApi'; -const cachedRundownPlaceholder = { rundown: [], revision: -1 }; +// revision is -1 so that the remote revision is higher +const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; -// TODO: can we leverage structural sharing to see if data has changed? export default function useRundown() { - const { data, status, isError, refetch, isFetching } = useQuery({ + const { data, status, isError, refetch, isFetching } = useQuery({ queryKey: RUNDOWN, queryFn: fetchCachedRundown, placeholderData: cachedRundownPlaceholder, @@ -17,13 +18,24 @@ export default function useRundown() { retryDelay: (attempt) => attempt * 2500, refetchInterval: queryRefetchInterval, networkMode: 'always', - // structuralSharing: (oldData: GetRundownCached | undefined, newData: GetRundownCached) => { - // if (oldData === undefined) { - // return cachedRundownPlaceholder; - // } - // const hasDataChanged = oldData?.revision === newData.revision; - // return hasDataChanged ? oldData : newData; - // }, }); - return { data: data?.rundown ?? [], status, isError, refetch, isFetching }; + return { data: data ?? cachedRundownPlaceholder, status, isError, refetch, isFetching }; +} + +export function useFlatRundown() { + const { data, status } = useRundown(); + + const [prevRevision, setPrevRevision] = useState(-1); + 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); + setPrevRevision(data.revision); + } + }, [data.order, data.revision, data.rundown, prevRevision]); + + return { data: flatRunDown, status }; } diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 4873b6067..d9c818e2c 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { GetRundownCached, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; -import { getPreviousEvent, swapOntimeEvents } from 'ontime-utils'; +import { isOntimeEvent, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { reorderArray, swapEventData } from 'ontime-utils'; import { RUNDOWN } from '../api/apiConstants'; import { logAxiosError } from '../api/apiUtils'; @@ -34,8 +34,6 @@ export const useEventAction = () => { * @private */ const _addEventMutation = useMutation({ - // Mutation finished, failed or successful - // Fetch anyway, just to be sure mutationFn: requestPostEvent, onSettled: () => { queryClient.invalidateQueries({ queryKey: RUNDOWN }); @@ -69,10 +67,12 @@ export const useEventAction = () => { after: options?.after, }; - const rundown = queryClient.getQueryData(RUNDOWN)?.rundown ?? []; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this has a value + const rundownData = queryClient.getQueryData(RUNDOWN)!; + const { rundown } = rundownData; if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) { - const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId); + const previousEvent = rundown[applicationOptions.lastEventId]; if (isOntimeEvent(previousEvent)) { newEvent.timeStart = previousEvent.timeEnd; newEvent.timeEnd = previousEvent.timeEnd; @@ -90,7 +90,6 @@ export const useEventAction = () => { } try { - // @ts-expect-error -- we know that the object is well formed now await _addEventMutation.mutateAsync(newEvent); } catch (error) { logAxiosError('Failed adding event', error); @@ -111,18 +110,15 @@ 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) { + if (previousData && eventId) { // optimistically update object - const optimisticRundown = [...previousData.rundown]; - const index = optimisticRundown.findIndex((event) => event.id === newEvent.id); - if (index > -1) { - // @ts-expect-error -- we expect the event types to match - optimisticRundown[index] = { ...optimisticRundown[index], ...newEvent }; - - queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); - } + const newRundown = { ...previousData.rundown }; + // @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 }); } // Return a context with the previous and new events @@ -161,14 +157,25 @@ export const useEventAction = () => { const updateTimer = useCallback( async (eventId: string, field: TimeField, value: string) => { const getPreviousEnd = (): number => { - const rundown = queryClient.getQueryData(RUNDOWN)?.rundown ?? []; - if (rundown) { - const { previousEvent } = getPreviousEvent(rundown, eventId); - if (previousEvent) { - return previousEvent.timeEnd; + const cachedRundown = queryClient.getQueryData(RUNDOWN); + + if (!cachedRundown?.order || !cachedRundown?.rundown) { + return 0; + } + + const index = cachedRundown.order.indexOf(eventId); + if (index === 0) { + return 0; + } + let previousEnd = 0; + for (let i = index - 1; i >= 0; i--) { + const event = cachedRundown.rundown[cachedRundown.order[i]]; + if (isOntimeEvent(event)) { + previousEnd = event.timeEnd; + break; } } - return 0; + return previousEnd; }; let newValMillis = 0; @@ -209,25 +216,26 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousEvents = queryClient.getQueryData(RUNDOWN); + const previousEvents = queryClient.getQueryData(RUNDOWN); if (previousEvents) { - const updatedEvents = previousEvents.rundown.map((event) => { - const isEventEdited = ids.includes(event.id); + const eventIds = new Set(ids); + const newRundown = { ...previousEvents.rundown }; - if (isEventEdited && isOntimeEvent(event)) { - return { - ...event, - ...data, - }; + eventIds.forEach((eventId) => { + if (Object.hasOwn(newRundown, eventId)) { + const event = newRundown[eventId]; + if (isOntimeEvent(event)) { + newRundown[eventId] = { + ...event, + ...data, + }; + } } - - return event; }); - queryClient.setQueryData(RUNDOWN, { rundown: updatedEvents, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); } - // Return a context with the previous and new events return { previousEvents }; }, @@ -241,7 +249,7 @@ export const useEventAction = () => { }); const batchUpdateEvents = useCallback( - async (data: Partial, eventIds: string[]) => { + async (data: Partial, eventIds: string[]) => { try { await _batchUpdateEventsMutation.mutateAsync({ ids: eventIds, data }); } catch (error) { @@ -263,20 +271,19 @@ 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 optimisticRundown = [...previousData.rundown]; - const index = optimisticRundown.findIndex((event) => event.id === eventId); - if (index > -1) { - optimisticRundown.splice(index, 1); + const newOrder = previousData.order.filter((id) => id !== eventId); + const newRundown = { ...previousData.rundown }; + delete newRundown[eventId]; - queryClient.setQueryData(RUNDOWN, { - rundown: optimisticRundown, - revision: -1, - }); - } + queryClient.setQueryData(RUNDOWN, { + order: newOrder, + rundown: newRundown, + revision: -1, + }); } // Return a context with the previous and new events @@ -321,10 +328,10 @@ 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: [], revision: -1 }); + queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); // Return a context with the previous and new events return { previousData }; @@ -392,15 +399,13 @@ 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 optimisticRundown = [...previousData.rundown]; - const [reorderedItem] = optimisticRundown.splice(data.from, 1); - optimisticRundown.splice(data.to, 0, reorderedItem); + const newOrder = reorderArray(previousData.order, data.from, data.to); - queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); } // Return a context with the previous and new events @@ -450,15 +455,22 @@ 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 fromEventIndex = previousData.rundown.findIndex((event) => event.id === from); - const toEventIndex = previousData.rundown.findIndex((event) => event.id === to); + const newRundown = { ...previousData.rundown }; + const eventA = previousData.rundown[from]; + const eventB = previousData.rundown[to]; - const optimisticRundown = swapOntimeEvents(previousData.rundown, fromEventIndex, toEventIndex); + if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { + return; + } - queryClient.setQueryData(RUNDOWN, { rundown: optimisticRundown, revision: -1 }); + const { newA, newB } = swapEventData(eventA, eventB); + newRundown[from] = newA; + newRundown[to] = newB; + + queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); } // Return a context with the previous events @@ -482,8 +494,6 @@ export const useEventAction = () => { */ const swapEvents = useCallback( async ({ from, to }: SwapEntry) => { - // TODO: before calling `/swapEvents`, - // we should determine the events are of type `OntimeEvent` try { await _swapEvents.mutateAsync({ from, to }); } catch (error) { diff --git a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx index dfd0ef5d0..e6b27ba0e 100644 --- a/apps/client/src/features/cuesheet/CuesheetWrapper.tsx +++ b/apps/client/src/features/cuesheet/CuesheetWrapper.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { OntimeRundownEntry, ProjectData } from 'ontime-types'; import Empty from '../../common/components/state/Empty'; import { useEventAction } from '../../common/hooks/useEventAction'; import { useCuesheet } from '../../common/hooks/useSocket'; -import useRundown from '../../common/hooks-query/useRundown'; +import { useFlatRundown } from '../../common/hooks-query/useRundown'; import useUserFields from '../../common/hooks-query/useUserFields'; -import ExportModal, { ExportType } from '../modals/export-modal/ExportModal'; import CuesheetProgress from './cuesheet-progress/CuesheetProgress'; import CuesheetTableHeader from './cuesheet-table-header/CuesheetTableHeader'; @@ -17,13 +16,12 @@ import { makeCSV, makeTable } from './cuesheetUtils'; import styles from './CuesheetWrapper.module.scss'; export default function CuesheetWrapper() { - const { data: rundown } = useRundown(); + // TODO: can we use the normalised rundown for the table? + const { data: flatRundown, status: rundownStatus } = useFlatRundown(); const { data: userFields } = useUserFields(); const { updateEvent } = useEventAction(); const featureData = useCuesheet(); const columns = useMemo(() => makeCuesheetColumns(userFields), [userFields]); - const [isModalOpen, setIsModalOpen] = useState(false); - const [headerData, setheaderData] = useState(null); // Set window title useEffect(() => { @@ -32,7 +30,7 @@ export default function CuesheetWrapper() { const handleUpdate = useCallback( async (rowIndex: number, accessor: keyof OntimeRundownEntry, payload: unknown) => { - if (!rundown) { + if (!flatRundown || rundownStatus !== 'success') { return; } @@ -41,7 +39,7 @@ export default function CuesheetWrapper() { } // check if value is the same - const event = rundown[rowIndex]; + const event = flatRundown[rowIndex]; if (!event) { return; } @@ -69,41 +67,21 @@ export default function CuesheetWrapper() { console.error(error); } }, - [updateEvent, rundown], + [flatRundown, rundownStatus, updateEvent], ); const exportHandler = useCallback( - (headerData: ProjectData, exportType: ExportType) => { - if (!headerData || !rundown || !userFields) { + (headerData: ProjectData) => { + if (!userFields || !flatRundown || rundownStatus !== 'success') { return; } + const sheetData = makeTable(headerData, flatRundown, userFields); + const csvContent = makeCSV(sheetData); - let fileName = ''; - let url = ''; + const fileName = 'ontime rundown.csv'; - if (exportType === 'json') { - const jsonContent = JSON.stringify({ - headerData, - rundown, - userFields, - }); - - fileName = 'ontime export.json'; - - const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' }); - url = URL.createObjectURL(blob); - } else if (exportType === 'csv') { - const sheetData = makeTable(headerData, rundown, userFields); - const csvContent = makeCSV(sheetData); - - fileName = 'ontime export.csv'; - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - url = URL.createObjectURL(blob); - } else { - console.error('Invalid export type: ', exportType); - return; - } + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.setAttribute('href', url); @@ -114,36 +92,23 @@ export default function CuesheetWrapper() { URL.revokeObjectURL(url); return; }, - [rundown, userFields], + [flatRundown, rundownStatus, userFields], ); - const onModalClose = (exportType?: ExportType) => { - setIsModalOpen(false); - - if (!exportType) { - return; - } - - if (headerData) { - exportHandler(headerData, exportType); - } - }; - - const handleOpenModal = (projectData: ProjectData) => { - setheaderData(projectData); - setIsModalOpen(true); - }; - - if (!rundown || !userFields) { + if (!userFields || !flatRundown || rundownStatus !== 'success') { return ; } return (
- + - - +
); } diff --git a/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap b/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap index b5716d433..c648cdc1e 100644 --- a/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap +++ b/apps/client/src/features/cuesheet/__tests__/__snapshots__/utils.test.js.snap @@ -3,25 +3,14 @@ exports[`makeTable() > returns array of arrays with given fields 1`] = ` [ [ - "Ontime · Schedule Template", + "Ontime · Rundown export", ], [ - "Project Title", - "", + "Project title: test title", ], [ - "Project Description", - "", + "Project description: test description", ], - [ - "Public URL", - "", - ], - [ - "Backstage URL", - "", - ], - [], [ "Time Start", "Time End", diff --git a/apps/client/src/features/cuesheet/__tests__/utils.test.js b/apps/client/src/features/cuesheet/__tests__/utils.test.js index bab6a93d1..d3a1e6f64 100644 --- a/apps/client/src/features/cuesheet/__tests__/utils.test.js +++ b/apps/client/src/features/cuesheet/__tests__/utils.test.js @@ -59,7 +59,10 @@ describe('parseField()', () => { describe('makeTable()', () => { it('returns array of arrays with given fields', () => { - const headerData = {}; + const headerData = { + title: 'test title', + description: 'test description', + }; const tableData = [ { title: 'test title 1', diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss index f5a901f3e..944dbcc3b 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss +++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.module.scss @@ -9,7 +9,7 @@ $active-colour: $gray-500; } @mixin time { - font-family: "Open Sans Light", $ontime-font-family; + font-family: 'Open Sans Light', $ontime-font-family; font-size: 2rem; text-align: center; } @@ -22,8 +22,7 @@ $active-colour: $gray-500; height: max-content; column-gap: 2rem; - grid-template-areas: - 'event playback timer clock actions'; + grid-template-areas: 'event playback timer clock actions'; grid-template-columns: 1fr auto auto auto auto; align-items: center; justify-items: center; @@ -91,11 +90,12 @@ $active-colour: $gray-500; display: flex; align-items: center; gap: 0.5rem; - font-size: 1.125rem; color: $label-colour; height: 100%; + font-size: 1rem; - .actionIcon { + .actionIcon, + .actionText { cursor: pointer; &.enabled { @@ -106,6 +106,10 @@ $active-colour: $gray-500; color: $active-colour; } } + + .actionIcon { + font-size: 1.25rem; + } } @media (min-width: 1200px) { diff --git a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx index d260f42e6..cc6970420 100644 --- a/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx +++ b/apps/client/src/features/cuesheet/cuesheet-table-header/CuesheetTableHeader.tsx @@ -8,6 +8,7 @@ import { Playback, ProjectData } from 'ontime-types'; import PlaybackIcon from '../../../common/components/playback-icon/PlaybackIcon'; import useFullscreen from '../../../common/hooks/useFullscreen'; import useProjectData from '../../../common/hooks-query/useProjectData'; +import { cx } from '../../../common/utils/styleUtils'; import { tooltipDelayFast } from '../../../ontimeConfig'; import { useCuesheetSettings } from '../store/CuesheetSettings'; @@ -58,12 +59,18 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh
- toggleFollow()} className={`${style.actionIcon} ${followSelected ? style.enabled : ''}`}> + toggleFollow()} + className={cx([style.actionIcon, followSelected ? style.enabled : null])} + > - toggleSettings()} className={`${style.actionIcon} ${showSettings ? style.enabled : ''}`}> + toggleSettings()} + className={cx([style.actionIcon, showSettings ? style.enabled : null])} + > @@ -73,8 +80,8 @@ export default function CuesheetTableHeader({ handleExport, featureData }: Cuesh - - Export + + Export CSV
diff --git a/apps/client/src/features/cuesheet/cuesheetUtils.ts b/apps/client/src/features/cuesheet/cuesheetUtils.ts index 13619dfac..8fce1b31c 100644 --- a/apps/client/src/features/cuesheet/cuesheetUtils.ts +++ b/apps/client/src/features/cuesheet/cuesheetUtils.ts @@ -39,14 +39,9 @@ export const parseField = (field: T, data: unkn * @return {(string[])[]} */ export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, userFields: UserFields): string[][] => { - const data = [ - ['Ontime · Schedule Template'], - ['Project Title', headerData?.title || ''], - ['Project Description', headerData?.description || ''], - ['Public URL', headerData?.publicUrl || ''], - ['Backstage URL', headerData?.backstageUrl || ''], - [], - ]; + const data = [['Ontime · Rundown export']]; + if (headerData.title) data.push([`Project title: ${headerData.title}`]); + if (headerData.description) data.push([`Project description: ${headerData.description}`]); const fieldOrder: OntimeEntryCommonKeys[] = [ 'timeStart', diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index d8230e2b0..10dfc438b 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -5,7 +5,6 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary' import AppSettings from '../app-settings/AppSettings'; import { SettingsOptionId, useSettingsStore } from '../app-settings/settingsStore'; import MenuBar from '../menu/MenuBar'; -import AboutModal from '../modals/about-modal/AboutModal'; import QuickStart from '../modals/quick-start/QuickStart'; import SheetsModal from '../modals/sheets-modal/SheetsModal'; import UploadModal from '../modals/upload-modal/UploadModal'; @@ -16,7 +15,6 @@ import styles from './Editor.module.scss'; const Rundown = lazy(() => import('../rundown/RundownExport')); const TimerControl = lazy(() => import('../control/playback/TimerControlExport')); const MessageControl = lazy(() => import('../control/message/MessageControlExport')); - const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal')); const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal')); @@ -35,7 +33,6 @@ export default function Editor() { onOpen: onIntegrationModalOpen, onClose: onIntegrationModalClose, } = useDisclosure(); - const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure(); const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure(); const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure(); @@ -52,7 +49,6 @@ export default function Editor() { - @@ -66,8 +62,6 @@ export default function Editor() { onUploadOpen={onUploadModalOpen} isIntegrationOpen={isIntegrationModalOpen} onIntegrationOpen={onIntegrationModalOpen} - isAboutOpen={isAboutModalOpen} - onAboutOpen={onAboutModalOpen} isQuickStartOpen={isQuickStartOpen} onQuickStartOpen={onQuickStartOpen} openSettings={handleSettings} diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 4d3ca2e8a..ddb9407f3 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect } from 'react'; import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoCloud } from '@react-icons/all-files/io5/IoCloud'; @@ -6,21 +6,17 @@ import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand'; import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; -import { IoHelp } from '@react-icons/all-files/io5/IoHelp'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline'; -import { IoSaveOutline } from '@react-icons/all-files/io5/IoSaveOutline'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { IoSnowOutline } from '@react-icons/all-files/io5/IoSnowOutline'; -import { downloadCSV, downloadRundown } from '../../common/api/ontimeApi'; import QuitIconBtn from '../../common/components/buttons/QuitIconBtn'; import TooltipActionBtn from '../../common/components/buttons/TooltipActionBtn'; import useElectronEvent from '../../common/hooks/useElectronEvent'; import { AppMode, useAppMode } from '../../common/stores/appModeStore'; import { cx } from '../../common/utils/styleUtils'; -import ExportModal, { ExportType } from '../modals/export-modal/ExportModal'; import RundownMenu from './RundownMenu'; @@ -34,8 +30,6 @@ interface MenuBarProps { onUploadOpen: () => void; isIntegrationOpen: boolean; onIntegrationOpen: () => void; - isAboutOpen: boolean; - onAboutOpen: () => void; isQuickStartOpen: boolean; onQuickStartOpen: () => void; isSheetsOpen: boolean; @@ -65,8 +59,6 @@ const MenuBar = (props: MenuBarProps) => { onUploadOpen, isIntegrationOpen, onIntegrationOpen, - isAboutOpen, - onAboutOpen, isQuickStartOpen, onQuickStartOpen, openSettings, @@ -116,22 +108,6 @@ const MenuBar = (props: MenuBarProps) => { }; }, [handleKeyPress, isElectron]); - const [isModalOpen, setIsModalOpen] = useState(false); - - const onModalClose = (exportType?: ExportType) => { - setIsModalOpen(false); - - if (!exportType) { - return; - } - - if (exportType === 'json') { - downloadRundown(); - } else if (exportType === 'csv') { - downloadCSV(); - } - }; - return (
@@ -154,15 +130,6 @@ const MenuBar = (props: MenuBarProps) => { tooltip='Import project file' aria-label='Import project file' /> - } - isDisabled={appMode === AppMode.Run} - clickHandler={() => setIsModalOpen(true)} - tooltip='Export project file' - aria-label='Export project file' - /> -
@@ -221,17 +188,7 @@ const MenuBar = (props: MenuBarProps) => { tooltip='Settings' aria-label='Settings' /> -
- } - clickHandler={onAboutOpen} - tooltip='About' - aria-label='About' - size='sm' - /> -
+ void; -} - -export default function AboutModal(props: AboutModalProps) { - const { isOpen, onClose } = props; - - return ( - - - - - About Ontime - - - - -
- -
-
- Ontime - Free Open Source Software for managing rundowns and event timers - www.getontime.no -
-
- Current version - - {`You are currently using Ontime ${version}`} -
-
- Docs - Read the docs over at GitBook -
-
- Github - Follow the project on GitHub -
- -
-
-
-
-
- ); -} diff --git a/apps/client/src/features/modals/about-modal/UpdateChecker.tsx b/apps/client/src/features/modals/about-modal/UpdateChecker.tsx deleted file mode 100644 index cbca92e28..000000000 --- a/apps/client/src/features/modals/about-modal/UpdateChecker.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useState } from 'react'; -import { Button } from '@chakra-ui/react'; - -import { getLatestVersion, HasUpdate } from '../../../common/api/ontimeApi'; -import ModalLink from '../ModalLink'; - -import styles from '../Modal.module.scss'; - -interface UpdateCheckerProps { - version: string; -} - -type CheckFail = { - error: string; -}; - -type CheckIsLatest = { - latest: true; -}; - -type CheckRemote = CheckFail | CheckIsLatest | HasUpdate; - -export default function UpdateChecker(props: UpdateCheckerProps) { - const { version } = props; - const [updateMessage, setUpdateMessage] = useState(null); - const [isFetching, setIsFetching] = useState(false); - - /** - * Handles version comparison and returns component with message - */ - const versionCheck = async () => { - setIsFetching(true); - - try { - const latest = await getLatestVersion(); - - if (!latest.version.includes(version)) { - // new version, pass data to component - setUpdateMessage(latest); - } else { - setUpdateMessage({ latest: true }); - } - } catch { - setUpdateMessage({ error: 'Error reaching server' }); - } finally { - setIsFetching(false); - } - }; - - const disableButton = Boolean(updateMessage && 'version' in updateMessage); - - return ( -
- - -
- ); -} - -function ResolveUpdateMessage(props: { updateMessage: CheckRemote | null }) { - const { updateMessage } = props; - - if (updateMessage && 'error' in updateMessage) { - return {updateMessage.error}; - } - if (updateMessage && 'url' in updateMessage) { - return {`New version available: ${updateMessage.version}`}; - } - return null; -} diff --git a/apps/client/src/features/modals/export-modal/ExportModal.module.scss b/apps/client/src/features/modals/export-modal/ExportModal.module.scss deleted file mode 100644 index 603a30d24..000000000 --- a/apps/client/src/features/modals/export-modal/ExportModal.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -.buttonRow { - justify-content: space-between; - margin-top: $section-spacing; - display: flex; - gap: $section-spacing; - margin: 0 0.25rem; -} diff --git a/apps/client/src/features/modals/export-modal/ExportModal.tsx b/apps/client/src/features/modals/export-modal/ExportModal.tsx deleted file mode 100644 index 78bb1bbcb..000000000 --- a/apps/client/src/features/modals/export-modal/ExportModal.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react'; - -import styles from './ExportModal.module.scss'; - -export type ExportType = 'csv' | 'json'; - -interface ExportModalProps { - isOpen: boolean; - onClose: (type?: ExportType) => void; -} - -export default function ExportModal(props: ExportModalProps) { - const { isOpen, onClose } = props; - - return ( - - - - Download options - - - - - - - - ); -} diff --git a/apps/client/src/features/modals/upload-modal/UploadModal.tsx b/apps/client/src/features/modals/upload-modal/UploadModal.tsx index b647cafe6..23e88c6d4 100644 --- a/apps/client/src/features/modals/upload-modal/UploadModal.tsx +++ b/apps/client/src/features/modals/upload-modal/UploadModal.tsx @@ -150,6 +150,8 @@ export default function UploadModal({ onClose, isOpen }: UploadModalProps) { setSubmitting(true); try { await patchData({ rundown, userFields }); + // TODO: broken :( + // we need to normalise the data here queryClient.setQueryData(RUNDOWN, { rundown, revision: -1 }); queryClient.setQueryData(USERFIELDS, userFields); await queryClient.invalidateQueries({ diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 6a50f6328..c86d73c3e 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { isOntimeEvent, OntimeEvent, SupportedEvent, UserFields } from 'ontime-types'; -import { getFirstEvent, getLastEvent } from 'ontime-utils'; +import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils'; import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu'; import Empty from '../../common/components/state/Empty'; @@ -137,8 +137,8 @@ export default function Operator() { let isPast = Boolean(featureData.selectedEventId); const hidePast = isStringBoolean(searchParams.get('hidepast')); - const { firstEvent } = getFirstEvent(data); - const { lastEvent } = getLastEvent(data); + const { firstEvent } = getFirstEventNormal(data.rundown, data.order); + const { lastEvent } = getLastEventNormal(data.rundown, data.order); return (
@@ -163,7 +163,8 @@ export default function Operator() { )}
- {data.map((entry) => { + {data.order.map((eventId) => { + const entry = data.rundown[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 af2edf837..ef95b6c1d 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -1,8 +1,8 @@ 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 { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, Playback, SupportedEvent } from 'ontime-types'; -import { getFirst, getNext, getPrevious } from 'ontime-utils'; +import { isOntimeBlock, isOntimeDelay, isOntimeEvent, Playback, RundownCached, SupportedEvent } from 'ontime-types'; +import { getFirstNormal, getNextNormal, getPreviousNormal } from 'ontime-utils'; import { useEventAction } from '../../common/hooks/useEventAction'; import useFollowComponent from '../../common/hooks/useFollowComponent'; @@ -19,12 +19,12 @@ import style from './Rundown.module.scss'; const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { - entries: OntimeRundown; + data: RundownCached; } -export default function Rundown(props: RundownProps) { - const { entries } = props; - const [statefulEntries, setStatefulEntries] = useState(entries); +export default function Rundown({ data }: RundownProps) { + const { order, rundown } = data; + const [statefulEntries, setStatefulEntries] = useState(order); const featureData = useRundownEditor(); const { addEvent, reorderEvent } = useEventAction(); @@ -56,7 +56,7 @@ export default function Rundown(props: RundownProps) { } if (type === 'clone') { - const cursorEvent = entries.find((event) => event.id === cursor); + const cursorEvent = rundown[cursor]; if (cursorEvent?.type === SupportedEvent.Event) { const newEvent = cloneEvent(cursorEvent, cursorEvent.id); addEvent(newEvent); @@ -76,7 +76,7 @@ export default function Rundown(props: RundownProps) { addEvent({ type }, { after: cursor }); } }, - [addEvent, defaultPublic, entries, startTimeIsLastEnd], + [addEvent, rundown, defaultPublic, startTimeIsLastEnd], ); // Handle keyboard shortcuts @@ -91,21 +91,23 @@ export default function Rundown(props: RundownProps) { if (modKeysAlt) { switch (event.code) { case 'ArrowDown': { - if (entries.length < 1) { + if (order.length < 1) { return; } - const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor)?.nextEvent; + const nextEvent = + cursor == null ? getFirstNormal(rundown, order) : getNextNormal(rundown, order, cursor)?.nextEvent; if (nextEvent) { // moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event); } break; } case 'ArrowUp': { - if (entries.length < 1) { + if (order.length < 1) { return; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before - const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor).previousEvent; + const previousEvent = + cursor == null ? getFirstNormal(rundown, order) : getPreviousNormal(rundown, order, cursor).previousEvent; if (previousEvent) { // moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event); } @@ -133,32 +135,30 @@ export default function Rundown(props: RundownProps) { } } } else if (modKeysCtrlAlt) { - if (entries.length < 2 || cursor == null) { + if (order.length < 2 || cursor == null) { return; } if (event.code == 'ArrowDown') { - const { nextEvent, nextIndex } = getNext(entries, cursor); + const { nextEvent, nextIndex } = getNextNormal(rundown, order, cursor); if (nextEvent && nextIndex !== null) { reorderEvent(cursor, nextIndex - 1, nextIndex); } } else if (event.code == 'ArrowUp') { - const { previousEvent, previousIndex } = getPrevious(entries, cursor); + const { previousEvent, previousIndex } = getPreviousNormal(rundown, order, cursor); if (previousEvent && previousIndex !== null) { reorderEvent(cursor, previousIndex + 1, previousIndex); } } } }, - [cursor, entries, insertAtCursor, reorderEvent], + [cursor, insertAtCursor, order, rundown, reorderEvent], ); // we copy the state from the store here // to workaround async updates on the drag mutations useEffect(() => { - if (entries) { - setStatefulEntries(entries); - } - }, [entries]); + setStatefulEntries(order); + }, [order]); // listen to keys useEffect(() => { @@ -193,7 +193,7 @@ export default function Rundown(props: RundownProps) { } }; - if (statefulEntries?.length < 1) { + if (statefulEntries.length < 1) { return insertAtCursor(SupportedEvent.Event, null)} />; } @@ -208,39 +208,46 @@ export default function Rundown(props: RundownProps) {
- {statefulEntries.map((entry, index) => { + {statefulEntries.map((eventId, index) => { + // 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 event = rundown[eventId]; + if (!event) { + return null; + } if (index === 0) { eventIndex = 0; } let isFirstEvent = false; - if (isOntimeEvent(entry)) { + if (isOntimeEvent(event)) { isFirstEvent = eventIndex === 0; // event indexes are 1 based in frontend eventIndex++; if (!isFirstEvent) { previousEnd = thisEnd; } - thisEnd = entry.timeEnd; - previousEventId = entry.id; + thisEnd = event.timeEnd; + previousEventId = event.id; } - const isLast = index === entries.length - 1; - const isSelected = featureData?.selectedEventId === entry.id; - const isNext = featureData?.nextEventId === entry.id; - const hasCursor = entry.id === cursor; + const isLast = index === order.length - 1; + const isSelected = featureData?.selectedEventId === event.id; + const isNext = featureData?.nextEventId === event.id; + const hasCursor = event.id === cursor; if (isSelected) { isPast = false; } return ( - +
- {entry.type === SupportedEvent.Event &&
{eventIndex}
} -
+ {isOntimeEvent(event) &&
{eventIndex}
} +
)} diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index fe9c7f467..127fa2895 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,18 +1,8 @@ import { useCallback } from 'react'; -import { - GetRundownCached, - isOntimeEvent, - MaybeNumber, - OntimeEvent, - OntimeRundownEntry, - Playback, - SupportedEvent, -} from 'ontime-types'; +import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; -import { RUNDOWN } from '../../common/api/apiConstants'; import { useEventAction } from '../../common/hooks/useEventAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; -import { ontimeQueryClient } from '../../common/queryClient'; import { useAppMode } from '../../common/stores/appModeStore'; import { useEditorSettings } from '../../common/stores/editorSettings'; import { useEmitLog } from '../../common/stores/logger'; @@ -103,28 +93,20 @@ export default function RundownEntry(props: RundownEntryProps) { case 'update': { // Handles and filters update requests const { field, value } = payload as FieldValue; + if (field === undefined || value === undefined) { + return; + } const newData: Partial = { id: data.id }; // if selected events are more than one // we need to bulk edit if (selectedEvents.size > 1) { const changes: Partial = { [field]: value }; - const rundown = ontimeQueryClient.getQueryData(RUNDOWN)?.rundown ?? []; - const idsOfRundownEvents = rundown.filter(isOntimeEvent).map((event) => event.id); - - const eventIds = [...selectedEvents.keys()]; - // check every selected event id to see if they match rundown event ids - const areIdsValid = eventIds.every((eventId) => idsOfRundownEvents.includes(eventId)); - - if (!areIdsValid) { - return; - } - - batchUpdateEvents(changes, eventIds); + batchUpdateEvents(changes, Array.from(selectedEvents)); return clearSelectedEvents(); } if (field in data) { - // @ts-expect-error not sure how to type this + // @ts-expect-error -- not sure how to type this newData[field] = value; return updateEvent(newData); } diff --git a/apps/client/src/features/rundown/RundownWrapper.tsx b/apps/client/src/features/rundown/RundownWrapper.tsx index cf552906f..8a9971271 100644 --- a/apps/client/src/features/rundown/RundownWrapper.tsx +++ b/apps/client/src/features/rundown/RundownWrapper.tsx @@ -10,7 +10,7 @@ export default function RundownWrapper() { return (
- {status === 'success' && data ? : } + {status === 'success' && data ? : }
); } 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 053b531f9..3087c1170 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.module.scss @@ -22,3 +22,10 @@ @include drag-style; grid-area: drag; } + +.actionButtons { + grid-area: btns; + display: flex; + align-items: center; + gap: 0.5rem; +} \ No newline at end of file diff --git a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx index c86fb149b..136035375 100644 --- a/apps/client/src/features/rundown/delay-block/DelayBlock.tsx +++ b/apps/client/src/features/rundown/delay-block/DelayBlock.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react'; -import { Button, HStack } from '@chakra-ui/react'; +import { Button } from '@chakra-ui/react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { IoCheckmark } from '@react-icons/all-files/io5/IoCheckmark'; @@ -72,7 +72,7 @@ export default function DelayBlock(props: DelayBlockProps) { - +
@@ -80,7 +80,7 @@ export default function DelayBlock(props: DelayBlockProps) { Cancel - +
); } diff --git a/apps/client/src/features/rundown/event-block/EventBlock.tsx b/apps/client/src/features/rundown/event-block/EventBlock.tsx index 3815256cc..a4baa934e 100644 --- a/apps/client/src/features/rundown/event-block/EventBlock.tsx +++ b/apps/client/src/features/rundown/event-block/EventBlock.tsx @@ -10,31 +10,17 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical'; import { EndAction, MaybeNumber, OntimeEvent, Playback, TimerType } from 'ontime-types'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; -import useRundown from '../../../common/hooks-query/useRundown'; import copyToClipboard from '../../../common/utils/copyToClipboard'; -import { isMacOS } from '../../../common/utils/deviceUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; import type { EventItemActions } from '../RundownEntry'; import { useEventIdSwapping } from '../useEventIdSwapping'; -import { EditMode, useEventSelection } from '../useEventSelection'; +import { getSelectionMode, useEventSelection } from '../useEventSelection'; import EventBlockInner from './EventBlockInner'; import RundownIndicators from './RundownIndicators'; import style from './EventBlock.module.scss'; -const getEditMode = (event: MouseEvent): EditMode => { - if ((isMacOS() && event.metaKey) || event.ctrlKey) { - return 'ctrl'; - } - - if (event.shiftKey) { - return 'shift'; - } - - return 'click'; -}; - interface EventBlockProps { cue: string; timeStart: number; @@ -95,7 +81,6 @@ export default function EventBlock(props: EventBlockProps) { } = props; const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping(); const { selectedEvents, setSelectedEvents } = useEventSelection(); - const { data: rundown = [] } = useRundown(); const handleRef = useRef(null); const [isVisible, setIsVisible] = useState(false); @@ -235,8 +220,10 @@ export default function EventBlock(props: EventBlockProps) { return; } - const editMode = getEditMode(event); - return setSelectedEvents({ id: eventId, index: eventIndex, rundown, editMode }); + // UI indexes are 1 based + const index = eventIndex - 1; + const editMode = getSelectionMode(event); + return setSelectedEvents({ id: eventId, index, selectMode: editMode }); // moveCursorTo(eventId, true); }; diff --git a/apps/client/src/features/rundown/event-editor/EventEditor.tsx b/apps/client/src/features/rundown/event-editor/EventEditor.tsx index b99a38d25..b81328117 100644 --- a/apps/client/src/features/rundown/event-editor/EventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/EventEditor.tsx @@ -36,24 +36,30 @@ export type EditorUpdateFields = export default function EventEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); const { data } = useRundown(); + const { order, rundown } = data; const { updateEvent } = useEventAction(); const [event, setEvent] = useState(null); useEffect(() => { - if (!data) { + if (order.length === 0) { setEvent(null); return; } - const event = data.find((event) => selectedEvents.has(event.id)); + const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); + if (!selectedEventId) { + setEvent(null); + return; + } + const event = rundown[selectedEventId]; if (event && isOntimeEvent(event)) { setEvent(event); } else { setEvent(null); } - }, [data, selectedEvents]); + }, [order, rundown, selectedEvents]); const handleSubmit = useCallback( (field: EditorUpdateFields, value: string) => { diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index 7308266a1..52fbcb869 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,12 +1,17 @@ -import { isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { MouseEvent } from 'react'; +import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types'; import { create } from 'zustand'; -export type EditMode = 'shift' | 'click' | 'ctrl'; +import { RUNDOWN } from '../../common/api/apiConstants'; +import { ontimeQueryClient } from '../../common/queryClient'; +import { isMacOS } from '../../common/utils/deviceUtils'; + +export type SelectionMode = 'shift' | 'click' | 'ctrl'; interface EventSelectionStore { selectedEvents: Set; anchoredIndex: number | null; - setSelectedEvents: (selectionArgs: { id: string; index: number; rundown: OntimeRundown; editMode: EditMode }) => void; + setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void; clearSelectedEvents: () => void; } @@ -14,72 +19,79 @@ export const useEventSelection = create()((set, get) => ({ selectedEvents: new Set(), anchoredIndex: null, setSelectedEvents: (selectionArgs) => { - const { id, index: eventIndex, rundown, editMode } = selectionArgs; - // event indexes are not 0 based - const index = eventIndex - 1; - + const { id, index, selectMode } = selectionArgs; const { selectedEvents, anchoredIndex } = get(); - if (editMode === 'click') { + // on click, we replace selection with event + if (selectMode === 'click') { return set({ selectedEvents: new Set([id]), anchoredIndex: index }); } - if (editMode === 'ctrl') { - if (selectedEvents.has(id)) { - const eventIds = rundown.reduce( - (newRundown, event, i) => { - if (isOntimeEvent(event) && selectedEvents.has(id)) { - return newRundown.concat({ id: event.id, index: i }); - } - - return newRundown; - }, - [] as { id: string; index: number }[], - ); - - // find the next available higher index - // if unavailable, then grab the last index of events - const newAnchoredIndex = eventIds.find(({ index: eventIndex }) => eventIndex > index) ?? eventIds.at(-1); - - selectedEvents.delete(id); + // on ctrl + click, we toggle the selection of that event + if (selectMode === 'ctrl') { + const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); + if (!rundownData) return; + // if it doesnt exist, simply add to the list and set an anchor + if (!selectedEvents.has(id)) { return set({ - selectedEvents, - anchoredIndex: newAnchoredIndex?.index ?? 0, - }); - } - - return set({ - selectedEvents: selectedEvents.add(id), - anchoredIndex: index, - }); - } - - if (editMode === 'shift') { - const eventIds = rundown.filter(isOntimeEvent); - - if (anchoredIndex === null) { - const eventsUntilIndex = eventIds.slice(0, eventIndex).map((event) => event.id); - - return set({ selectedEvents: new Set(eventsUntilIndex), anchoredIndex: index }); - } - - if (anchoredIndex > index) { - const eventsFromIndex = eventIds.slice(index, anchoredIndex + 1).map((event) => event.id); - - return set({ - selectedEvents: new Set([...selectedEvents, ...eventsFromIndex]), + selectedEvents: selectedEvents.add(id), anchoredIndex: index, }); } - const eventsUntilIndex = eventIds.slice(anchoredIndex, eventIndex).map((event) => event.id); + // if event is already selected, we remove it from selection + // and set the anchor to the event after + selectedEvents.delete(id); + + const nextIndex = rundownData.order.findIndex( + (eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId), + ); + + // if we didnt find anything after, set the anchor to the last event + return set({ + selectedEvents, + anchoredIndex: nextIndex < 0 ? rundownData.order.length - 1 : nextIndex, + }); + } + + // on shift + click, we select a range of events up to the clicked event + if (selectMode === 'shift') { + 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]; + if (isOntimeEvent(event)) { + events.push(event); + } + }); + + const start = anchoredIndex === null ? 0 : Math.min(anchoredIndex, index); + 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); return set({ - selectedEvents: new Set([...selectedEvents, ...eventsUntilIndex]), + selectedEvents: new Set([...selectedEvents, ...selectedEventIds]), anchoredIndex: index, }); } }, clearSelectedEvents: () => set({ selectedEvents: new Set() }), })); + +export function getSelectionMode(event: MouseEvent): SelectionMode { + if ((isMacOS() && event.metaKey) || event.ctrlKey) { + return 'ctrl'; + } + + if (event.shiftKey) { + return 'shift'; + } + + return 'click'; +} diff --git a/apps/client/src/features/viewers/ViewWrapper.tsx b/apps/client/src/features/viewers/ViewWrapper.tsx index 2a638d7ab..790211a4d 100644 --- a/apps/client/src/features/viewers/ViewWrapper.tsx +++ b/apps/client/src/features/viewers/ViewWrapper.tsx @@ -4,7 +4,7 @@ import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessa import { useStore } from 'zustand'; import useProjectData from '../../common/hooks-query/useProjectData'; -import useRundown from '../../common/hooks-query/useRundown'; +import { useFlatRundown } from '../../common/hooks-query/useRundown'; import useSettings from '../../common/hooks-query/useSettings'; import useViewSettings from '../../common/hooks-query/useViewSettings'; import { runtimeStore } from '../../common/stores/runtime'; @@ -42,7 +42,7 @@ const withData =

(Component: ComponentType

) => { const isMirrored = useViewOptionsStore((state) => state.mirror); // HTTP API data - const { data: rundownData } = useRundown(); + const { data: rundownData } = useFlatRundown(); const { data: project } = useProjectData(); const { data: viewSettings } = useViewSettings(); const { data: settings } = useSettings(); diff --git a/apps/server/package.json b/apps/server/package.json index 70b298cb5..cffdebe1e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -47,18 +47,19 @@ }, "scripts": { "addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js", - "setdb": "shx cp ../../demo-db/db.json src/preloaded-db/db.json", - "postinstall": "pnpm addversion && pnpm setdb", + "set:demoproject": "shx cp ../../demo-db/db.json src/preloaded-db/db.json", + "set:testproject": "shx cp ../../demo-db/db.json test-db/db.json", + "postinstall": "pnpm addversion && pnpm set:demoproject && pnpm set:testproject", "dev": "cross-env NODE_ENV=development nodemon --exec \"ts-node-esm\" ./src/index.ts", "dev:inspect": "cross-env NODE_ENV=development nodemon --exec \"node --inspect --loader ts-node/esm\" ./src/index.ts", "dev:test": "cross-env IS_TEST=true nodemon --exec \"ts-node-esm\" ./src/index.ts", - "prebuild": "pnpm setdb", - "build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs", - "build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs", - "build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs", - "build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs", - "build:localdocker": "NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs", - "build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs", + "prebuild": "pnpm set:demoproject", + "build": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", + "build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", + "build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --legal-comments=external --outfile=dist/index.cjs", + "build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs", + "build:localdocker": "NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --legal-comments=external --outfile=dist/docker.cjs", + "build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --legal-comments=external --outfile=dist/index.cjs", "lint": "eslint . --quiet", "lint-staged": "eslint", "test": "cross-env IS_TEST=true vitest", diff --git a/apps/server/src/controllers/integrationController.config.ts b/apps/server/src/controllers/integrationController.config.ts index 1f50538d8..b3fbdc0f8 100644 --- a/apps/server/src/controllers/integrationController.config.ts +++ b/apps/server/src/controllers/integrationController.config.ts @@ -1,7 +1,6 @@ -import { LogOrigin, OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types'; +import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types'; import { editEvent, getEventWithId } from '../services/rundown-service/RundownService.js'; import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js'; -import { logger } from '../classes/Logger.js'; const whitelistedPayload = { title: coerceString, @@ -62,9 +61,7 @@ export function updateEvent( propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration; } - editEvent({ id: eventId, ...propertiesToUpdate }).then(() => { - logger.info(LogOrigin.Playback, `Updated ${propertyName} of event with ID ${eventId} to ${newValue}`); - }); + editEvent({ id: eventId, ...propertiesToUpdate }); } else { throw new Error(`Event with ID ${eventId} not found`); } diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index f0aa3d5bb..1275d31f9 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -32,9 +32,7 @@ import { import { oscIntegration } from '../services/integration-service/OscIntegration.js'; import { httpIntegration } from '../services/integration-service/HttpIntegration.js'; import { logger } from '../classes/Logger.js'; -import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js'; -import { runtimeCacheStore } from '../stores/cachingStore.js'; -import { delayedRundownCacheKey } from '../services/rundown-service/rundownCache.js'; +import { deleteAllEvents, notifyChanges, setRundown } from '../services/rundown-service/RundownService.js'; import { integrationService } from '../services/integration-service/IntegrationService.js'; import { getProjectFiles } from '../utils/getFileListFromFolder.js'; import { configService } from '../services/ConfigService.js'; @@ -101,12 +99,14 @@ const parseAndApply = async (file, _req, res, options) => { runtimeService.stop(); const newRundown = result.rundown || []; + const { rundown, ...rest } = result; if (options?.onlyRundown === 'true') { - await DataProvider.setRundown(newRundown); + setRundown(newRundown ?? []); } else { - await DataProvider.mergeIntoData(result); + await DataProvider.mergeIntoData(rest); + setRundown(rundown ?? []); } - notifyChanges({ timer: true, external: true, reset: true }); + notifyChanges({ timer: true, external: true }); }; /** @@ -403,15 +403,14 @@ export async function patchPartialProjectFile(req, res) { osc: req.body?.osc, aliases: req.body?.aliases, userFields: req.body?.userFields, - rundown: req.body?.rundown, }; + const maybeRundown = req.body?.rundown; await DataProvider.mergeIntoData(patchDb); - if (patchDb.rundown !== undefined) { + if (maybeRundown !== undefined) { // it is likely cheaper to invalidate cache than to calculate diff runtimeService.stop(); - runtimeCacheStore.invalidate(delayedRundownCacheKey); - notifyChanges({ external: true, reset: true }); + await setRundown(maybeRundown); } res.status(200).send(); } catch (error) { @@ -453,7 +452,6 @@ export async function previewExcel(req, res) { const data = await parseFile(file, req, res, options); res.status(200).send(data); } catch (error) { - console.log(error) res.status(500).send({ message: error.toString() }); } } @@ -626,6 +624,7 @@ export const createProjectFile: RequestHandler = async (req, res) => { return res.status(409).send({ message: errors.join(', ') }); } + console.log(`----------------> Creating directory createProjectFile: ${projectFilePath}`); await writeFile(projectFilePath, JSON.stringify(dbModel)); res.status(200).send({ diff --git a/apps/server/src/controllers/rundownController.ts b/apps/server/src/controllers/rundownController.ts index dbb019453..b514dea4f 100644 --- a/apps/server/src/controllers/rundownController.ts +++ b/apps/server/src/controllers/rundownController.ts @@ -1,4 +1,4 @@ -import { GetRundownCached } from 'ontime-types'; +import { RundownCached } from 'ontime-types'; import { Request, Response, RequestHandler } from 'express'; @@ -13,19 +13,20 @@ import { reorderEvent, swapEvents, } from '../services/rundown-service/RundownService.js'; -import { getDelayedRundown, getRundownCache } from '../services/rundown-service/rundownCache.js'; +import { get } from '../services/rundown-service/rundownCache.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.js'; // Create controller for GET request to '/events' // Returns - export const rundownGetAll: RequestHandler = async (_req, res) => { - const delayedRundown = getDelayedRundown(); - res.json(delayedRundown); + const rundown = DataProvider.getRundown(); + res.json(rundown); }; // Create controller for GET request to '/events/cached' // Returns - -export const rundownGetCached: RequestHandler = async (_req: Request, res: Response) => { - const cachedRundown = getRundownCache(); +export const rundownGetCached: RequestHandler = async (_req: Request, res: Response) => { + const cachedRundown = get(); res.json(cachedRundown); }; diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index 172a1c256..03721800a 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -32,7 +32,6 @@ export const event: Omit = { export const delay: Omit = { duration: 0, type: SupportedEvent.Delay, - revision: 0, }; export const block: Omit = { diff --git a/apps/server/src/preloaded-db/.keep b/apps/server/src/preloaded-db/.keep index e69de29bb..1eb607c2f 100644 --- a/apps/server/src/preloaded-db/.keep +++ b/apps/server/src/preloaded-db/.keep @@ -0,0 +1 @@ +This directory holds the demo file shipped with Ontime diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index 085057e03..da6299f86 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -1,119 +1,87 @@ import { LogOrigin, - OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent, - SupportedEvent, + OntimeRundown, + OntimeRundownEntry, + isOntimeBlock, + isOntimeDelay, isOntimeEvent, } from 'ontime-types'; -import { generateId, getCueCandidate } from 'ontime-utils'; +import { getCueCandidate } from 'ontime-utils'; + import { DataProvider } from '../../classes/data-provider/DataProvider.js'; import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import { sendRefetch } from '../../adapters/websocketAux.js'; -import { runtimeCacheStore } from '../../stores/cachingStore.js'; -import { - cachedAdd, - cachedApplyDelay, - cachedClear, - cachedDelete, - cachedEdit, - cachedBatchEdit, - cachedReorder, - cachedSwap, - delayedRundownCacheKey, -} from './rundownCache.js'; import { logger } from '../../classes/Logger.js'; import { createEvent } from '../../utils/parser.js'; import { updateNumEvents } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; -/** - * Forces rundown to be recalculated - * To be used when we know the rundown has changed completely - */ -export function forceReset() { - runtimeService.reset(); - runtimeCacheStore.invalidate(delayedRundownCacheKey); +import * as cache from './rundownCache.js'; + +function generateEvent(eventData: Partial | Partial | Partial) { + // we discard any UI provided events and add our own + const id = cache.getUniqueId(); + + if (isOntimeEvent(eventData)) { + return createEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent; + } + + if (isOntimeDelay(eventData)) { + return { ...delayDef, duration: eventData.duration ?? 0, id } as OntimeDelay; + } + + if (isOntimeBlock(eventData)) { + return { ...blockDef, title: eventData?.title ?? '', id } as OntimeBlock; + } + + throw new Error('Invalid event type'); } + /** * @description creates a new event with given data * @param {object} eventData - * @return {unknown[]} + * @return {OntimeRundownEntry} */ export async function addEvent(eventData: Partial | Partial | Partial) { - let newEvent: Partial = {}; - const id = generateId(); - - let insertIndex = 0; + // if the user didnt provide an index, we add the event to start + let atIndex = 0; if (eventData?.after !== undefined) { - const index = DataProvider.getIndexOf(eventData.after); - if (index < 0) { + const previousIndex = cache.getIndexOf(eventData.after); + if (previousIndex < 0) { logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`); } else { - insertIndex = index + 1; + atIndex = previousIndex + 1; } } - switch (eventData.type) { - case SupportedEvent.Event: { - newEvent = createEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent; - break; - } - case SupportedEvent.Delay: - newEvent = { ...delayDef, duration: eventData.duration, id } as OntimeDelay; - break; - case SupportedEvent.Block: - newEvent = { ...blockDef, title: eventData.title, id } as OntimeBlock; - break; - } - delete eventData.after; - + // generate a fully formed event from the patch + const eventToAdd = generateEvent(eventData); // modify rundown - await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock); - - notifyChanges({ timer: [id], external: true }); - - // notify event loader that rundown size has changed - updateChangeNumEvents(); - - return newEvent; -} - -export async function editEvent(eventData: Partial | Partial | Partial) { - if (!eventData?.id) { - throw new Error('Event misses ID'); - } - if (isOntimeEvent(eventData) && eventData?.cue === '') { - throw new Error('Cue value invalid'); - } - - const newEvent = await cachedEdit(eventData.id, eventData); + const scopedMutation = cache.mutateCache(cache.add); + const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry }); notifyChanges({ timer: [newEvent.id], external: true }); + // notify runtime that rundown size has changed + updateChangeNumEvents(); + return newEvent; } -export async function batchEditEvents(ids: string[], data: Partial) { - await cachedBatchEdit(ids, data); - - // notify runtime service of changed events - runtimeService.update(ids); - - // advice socket subscribers of change - sendRefetch(); -} - /** * deletes event by its ID * @param eventId * @returns {Promise} */ export async function deleteEvent(eventId: string) { - await cachedDelete(eventId); + const scopedMutation = cache.mutateCache(cache.remove); + await scopedMutation({ eventId }); notifyChanges({ timer: [eventId], external: true }); + // notify event loader that rundown size has changed updateChangeNumEvents(); } @@ -123,10 +91,31 @@ export async function deleteEvent(eventId: string) { * @returns {Promise} */ export async function deleteAllEvents() { - await cachedClear(); + const scopedMutation = cache.mutateCache(cache.removeAll); + await scopedMutation({}); // no need to modify timer since we will reset - notifyChanges({ external: true, reset: true }); + notifyChanges({ external: true }); +} + +export async function editEvent(patch: Partial | Partial | Partial) { + if (isOntimeEvent(patch) && patch?.cue === '') { + throw new Error('Cue value invalid'); + } + + const scopedMutation = cache.mutateCache(cache.edit); + const { newEvent } = await scopedMutation({ patch, eventId: patch.id }); + + notifyChanges({ timer: [patch.id], external: true }); + + return newEvent; +} + +export async function batchEditEvents(ids: string[], data: Partial) { + const scopedMutation = cache.mutateCache(cache.batchEdit); + await scopedMutation({ patch: data, eventIds: ids }); + + notifyChanges({ timer: ids, external: true }); } /** @@ -137,7 +126,8 @@ export async function deleteAllEvents() { * @returns {Promise} */ export async function reorderEvent(eventId: string, from: number, to: number) { - const reorderedItem = await cachedReorder(eventId, from, to); + const scopedMutation = cache.mutateCache(cache.reorder); + const reorderedItem = await scopedMutation({ eventId, from, to }); notifyChanges({ timer: true, external: true }); @@ -145,7 +135,8 @@ export async function reorderEvent(eventId: string, from: number, to: number) { } export async function applyDelay(eventId: string) { - await cachedApplyDelay(eventId); + const scopedMutation = cache.mutateCache(cache.applyDelay); + await scopedMutation({ eventId }); notifyChanges({ timer: true, external: true }); } @@ -157,7 +148,8 @@ export async function applyDelay(eventId: string) { * @returns {Promise} */ export async function swapEvents(from: string, to: string) { - await cachedSwap(from, to); + const scopedMutation = cache.mutateCache(cache.swap); + await scopedMutation({ fromId: from, toId: to }); notifyChanges({ timer: true, external: true }); } @@ -174,7 +166,7 @@ function updateChangeNumEvents() { /** * Notify services of changes in the rundown */ -export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) { +export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) { if (options.timer) { // notify timer service of changed events // timer can be true or an array of changed IDs @@ -184,17 +176,20 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?: runtimeService.update(); } - if (options.reset) { - // force rundown to be recalculated - forceReset(); - } - if (options.external) { // advice socket subscribers of change sendRefetch(); } } +/** + * returns entire unfiltered rundown + * @return {array} + */ +export function getRundown(): OntimeRundown { + return DataProvider.getRundown(); +} + /** * returns all events of type OntimeEvent * @return {array} @@ -290,3 +285,9 @@ export function findNext(currentEventId?: string): OntimeEvent | null { const nextEvent = timedEvents.at(newIndex); return nextEvent ?? null; } + +export async function setRundown(rundown: OntimeRundown) { + await DataProvider.setRundown(rundown); + cache.init(rundown); + notifyChanges({ timer: true }); +} 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 788bfebab..5454d860b 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -1,7 +1,7 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; -import { applyDelay } from '../delayUtils.js'; +import { apply } from '../delayUtils.js'; -describe('_applyDelay() ', () => { +describe('apply() ', () => { describe('in a rundown without the delay field, persisted rundown', () => { it('applies delays', () => { const delayId = '1'; @@ -20,7 +20,7 @@ describe('_applyDelay() ', () => { { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); it('applies negative delays', () => { @@ -40,7 +40,7 @@ describe('_applyDelay() ', () => { { id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); it('maintains constant duration', () => { @@ -56,7 +56,7 @@ describe('_applyDelay() ', () => { { id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); }); @@ -126,7 +126,7 @@ describe('_applyDelay() ', () => { } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); it('applies negative delays', () => { @@ -194,7 +194,7 @@ describe('_applyDelay() ', () => { } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); it('maintains constant duration', () => { @@ -242,7 +242,7 @@ describe('_applyDelay() ', () => { } as OntimeEvent, ]; - const updatedRundown = applyDelay(delayId, testRundown); + const updatedRundown = apply(delayId, testRundown); expect(updatedRundown).toStrictEqual(expected); }); }); 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 180b8febf..454bce278 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,131 @@ import { EndAction, OntimeEvent, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types'; import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js'; +import { add, batchEdit, edit, remove, reorder, swap } from '../rundownCache.js'; + +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, persistedRundown: testRundown }); + expect(newRundown.length).toBe(1); + expect(newRundown[0]).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({ eventId: mockEvent.id, persistedRundown: testRundown }); + expect(newRundown.length).toBe(0); + }); +}); + +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 { newRundown, newEvent } = edit({ + eventId: mockEvent.id, + patch: mockEventPatch, + persistedRundown: testRundown, + }); + expect(newRundown.length).toBe(1); + expect(newEvent).toMatchObject({ + id: 'mock', + cue: 'patched', + type: SupportedEvent.Event, + }); + }); +}); + +describe('batchEdit() mutation', () => { + it('should correctly apply the patch to the events with the given IDs', () => { + const persistedRundown: 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 eventIds = ['1', '3']; + const patch = { cue: 'newData' }; + + const { newRundown } = batchEdit({ persistedRundown, 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' }, + ]); + }); +}); + +describe('reorder() mutation', () => { + it('should correctly reorder two events', () => { + const persistedRundown: 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 { newRundown } = reorder({ + persistedRundown, + eventId: persistedRundown[0].id, + from: 0, + to: persistedRundown.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 }, + ]); + }); +}); + +describe('swap() mutation', () => { + it('should correctly swap data between events', () => { + const persistedRundown: 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 { newRundown } = swap({ + persistedRundown, + fromId: persistedRundown[0].id, + toId: persistedRundown[1].id, + }); + + 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[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[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); + }); +}); + +/** + * + * + * + * + * + * + * + * + * + * + * + */ describe('calculateRuntimeDelays', () => { it('calculates all delays in a given rundown', () => { @@ -38,7 +163,6 @@ describe('calculateRuntimeDelays', () => { { duration: 600000, type: SupportedEvent.Delay, - revision: 0, id: '07986', }, { @@ -74,7 +198,6 @@ describe('calculateRuntimeDelays', () => { { duration: 1200000, type: SupportedEvent.Delay, - revision: 0, id: '7db42', }, { @@ -190,7 +313,6 @@ describe('getDelayAt()', () => { { duration: 600000, type: SupportedEvent.Delay, - revision: 0, id: '07986', }, { @@ -227,7 +349,6 @@ describe('getDelayAt()', () => { { duration: 1200000, type: SupportedEvent.Delay, - revision: 0, id: '7db42', }, { @@ -362,7 +483,6 @@ describe('calculateRuntimeDelaysFrom()', () => { { duration: 600000, type: SupportedEvent.Delay, - revision: 0, id: '07986', }, { @@ -399,7 +519,6 @@ describe('calculateRuntimeDelaysFrom()', () => { { duration: 1200000, type: SupportedEvent.Delay, - revision: 0, id: '7db42', }, { diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index f8ef23644..e5805aa85 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,6 +1,6 @@ import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types'; -import { deleteAtIndex } from '../../utils/arrayUtils.js'; +import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js'; /** * Calculates all delays in a given rundown @@ -94,9 +94,10 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number { * Applies delay from given event ID, deletes the delay event after * @param eventId * @param rundown + * @throws {Error} if event ID not found or is not a delay * @returns */ -export function applyDelay(eventId: string, rundown: OntimeRundown): OntimeRundown { +export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { const delayIndex = rundown.findIndex((event) => event.id === eventId); const delayEvent = rundown.at(delayIndex); diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 06a92a0b4..dcdd41d81 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -1,288 +1,260 @@ import { - GetRundownCached, isOntimeBlock, isOntimeDelay, isOntimeEvent, - OntimeBlock, - OntimeDelay, OntimeEvent, OntimeRundown, OntimeRundownEntry, } from 'ontime-types'; -import { swapOntimeEvents } from 'ontime-utils'; +import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData } from 'ontime-utils'; import { DataProvider } from '../../classes/data-provider/DataProvider.js'; -import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js'; -import { isProduction } from '../../setup.js'; -import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js'; import { createPatch } from '../../utils/parser.js'; -import { applyDelay, calculateRuntimeDelays, calculateRuntimeDelaysFromIndex, getDelayAt } from './delayUtils.js'; +import { apply } from './delayUtils.js'; + +type NormalisedRundown = Record; + +let rundown: NormalisedRundown = {}; +let order: string[] = []; +let revision = 0; +let isStale = true; /** - * Keep incremental revision number of rundown for runtime + * Utility initialises cache + * @param persistedRundown */ -let rundownRevision = 0; +export function init(persistedRundown: Readonly) { + // we decided to try and re-write this dataset for every change + // instead of maintaining logic to update it + rundown = {}; + order = []; -/** - * Key of rundown in cache - */ -export const delayedRundownCacheKey = 'delayed-rundown'; + let accumulatedDelay = 0; + for (let i = 0; i < persistedRundown.length; i++) { + const event = persistedRundown[i]; -/** - * Invalidates the cached rundown when an inconsistency is found - * will throw when not in production - * @param errorMessage - */ -export function invalidateFromError(errorMessage = 'Found mismatch between store and cache') { - if (isProduction) { - runtimeCacheStore.invalidate(delayedRundownCacheKey); - } else { - throw new Error(errorMessage); + // calculate delays + if (isOntimeDelay(event)) { + accumulatedDelay += event.duration; + } else if (isOntimeBlock(event)) { + accumulatedDelay = 0; + } else if (isOntimeEvent(event)) { + event.delay = accumulatedDelay; + } + + order.push(event.id); + rundown[event.id] = { ...event }; } + isStale = false; } /** - * Returns rundown with calculated delays - * Ensures request goes through the caching layer + * Returns an ID guaranteed to be unique + * @returns */ -export function getRundownCache(): GetRundownCached { - function calculateRundown() { - const rundown = DataProvider.getRundown(); - return calculateRuntimeDelays(rundown); +export function getUniqueId(persistedRundown: Readonly = getPersistedRundown()): string { + let id = ''; + do { + id = generateId(); + } while (!isIdUnique(persistedRundown, id)); + return id; +} + +export function isIdUnique(persistedRundown: Readonly, eventId: string) { + if (isStale) { + init(persistedRundown); } + return !Object.hasOwn(rundown, eventId); +} - const cached = getCached(delayedRundownCacheKey, calculateRundown); +export function getIndexOf(eventId: string) { + if (isStale) { + init(getPersistedRundown()); + } + return order.indexOf(eventId); +} +/** + * Utility function gets rundown from DataProvider + * @returns {OntimeRundown} + */ +export const getPersistedRundown = (): OntimeRundown => DataProvider.getRundown(); + +type RundownCache = { + rundown: NormalisedRundown; + order: string[]; + revision: number; +}; + +/** + * Returns cached data + * @returns {RundownCache} + */ +export function get(): Readonly { + if (isStale) { + console.time('rundownCache__init'); + init(getPersistedRundown()); + console.timeEnd('rundownCache__init'); + } return { - rundown: cached, - revision: rundownRevision, + rundown, + order, + revision, }; } +type CommonParams = { persistedRundown: OntimeRundown }; +type MutationParams = T & Partial; +type MutatingReturn = { + newRundown: OntimeRundown; + newEvent?: OntimeRundownEntry; +}; +type MutatingFn = (params: MutationParams) => MutatingReturn; /** - * Returns rundown with calculated delays - * Ensures request goes through the caching layer + * Decorators injects data into mutation + * @param mutation + * @returns */ -export function getDelayedRundown() { - function calculateRundown() { - const rundown = DataProvider.getRundown(); - return calculateRuntimeDelays(rundown); - } +export function mutateCache(mutation: MutatingFn) { + async function scopedMutation(params: T) { + const persistedRundown = getPersistedRundown(); + const { newEvent, newRundown } = mutation({ ...params, persistedRundown }); - return getCached(delayedRundownCacheKey, calculateRundown); + revision = revision + 1; + isStale = true; + + DataProvider.setRundown(newRundown); + // schedule the update to the next tick + + process.nextTick(() => { + console.time('rundownCache__init'); + init(newRundown); + console.timeEnd('rundownCache__init'); + }); + + // TODO: could we return a patch object? + return { newEvent }; + } + return scopedMutation; +} + +type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>; +export function add({ persistedRundown, atIndex, event }: AddArgs): Required { + const newEvent: OntimeRundownEntry = { ...event }; + const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown); + + return { newRundown, newEvent }; +} + +type RemoveArgs = MutationParams<{ eventId: string }>; +export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn { + const atIndex = persistedRundown.findIndex((event) => event.id === eventId); + const newRundown = deleteAtIndex(atIndex, persistedRundown); + + return { newRundown }; +} + +export function removeAll(): { newRundown: OntimeRundown } { + return { newRundown: [] }; } /** - * Adds an event in the rundown at given index, ensuring replication to delayed rundown cache - * @param eventIndex - * @param event + * Utility function for patching events + * @param eventFromRundown + * @param patch + * @returns */ -export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeDelay | OntimeBlock) { - // TODO: create wrapper function - const rundown = DataProvider.getRundown(); - const newRundown = insertAtIndex(eventIndex, event, rundown); - - const delayedRundown = getDelayedRundown(); - let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown); - - // update delay cache - if (isOntimeEvent(event)) { - // if it is an event, we need its delay - (newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown); - } else { - // if it is a block or delay, we invalidate from here - newDelayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, newDelayedRundown); +function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial): OntimeRundownEntry { + if (isOntimeEvent(eventFromRundown)) { + const newEvent = createPatch(eventFromRundown, patch as OntimeEvent); + newEvent.revision++; + return newEvent; } - - runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown); - // we need to delay updating this to ensure add operation happens on same dataset - await DataProvider.setRundown(newRundown); - - rundownRevision++; + // TODO: exhaustive check + return { ...eventFromRundown, ...patch } as OntimeRundownEntry; } -/** - * Edits an event in rundown, ensuring replication to delayed rundown cache - * @param eventId - * @param patchObject - */ -export async function cachedEdit( - eventId: string, - patchObject: Partial | Partial | Partial, -) { - const makeEvent = (eventFromRundown: OntimeRundownEntry): OntimeRundownEntry => { - if (isOntimeEvent(eventFromRundown)) { - const newEvent = createPatch(eventFromRundown, patchObject as OntimeEvent); - newEvent.revision++; - return newEvent; +type EditArgs = MutationParams<{ eventId: string; patch: Partial }>; +export function edit({ persistedRundown, eventId, patch }: EditArgs): Required { + const indexAt = persistedRundown.findIndex((event) => event.id === eventId); + + if (indexAt < 0) { + throw new Error('Event not found'); + } + + if (patch?.type && persistedRundown[indexAt].type !== patch.type) { + throw new Error('Invalid event type'); + } + + const eventInMemory = persistedRundown[indexAt]; + const newEvent = makeEvent(eventInMemory, patch); + const newRundown = [...persistedRundown]; + newRundown[indexAt] = newEvent; + + return { newRundown, newEvent }; +} + +type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial }>; +export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn { + const ids = new Set(eventIds); + + const newRundown = []; + for (let i = 0; i < persistedRundown.length; i++) { + if (ids.has(persistedRundown[i].id)) { + if (patch?.type && persistedRundown[i].type !== patch.type) { + continue; + } + const newEvent = makeEvent(persistedRundown[i], patch); + newRundown.push(newEvent); + } else { + newRundown.push(persistedRundown[i]); } + } + return { newRundown }; +} - return { ...eventFromRundown, ...patchObject } as OntimeRundownEntry; - }; - - const indexInMemory = DataProvider.getIndexOf(eventId); - if (indexInMemory < 0) { - throw new Error('No event with ID found'); +type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; +export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required { + const event = persistedRundown[from]; + if (!event || eventId !== event.id) { + throw new Error('Event not found'); } - const updatedRundown = DataProvider.getRundown(); - const eventFromRundown = updatedRundown[indexInMemory]; - - const isPatchObjectDifferentFromRundownEvent = Object.entries(patchObject).some( - ([key, value]) => eventFromRundown[key] !== value, - ); - - if (!isPatchObjectDifferentFromRundownEvent) { - return eventFromRundown; - } - - const newEvent = makeEvent(eventFromRundown); - updatedRundown[indexInMemory] = newEvent; - - let newDelayedRundown = getDelayedRundown(); - if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) { - invalidateFromError(); - } else { - newDelayedRundown[indexInMemory] = newEvent; - if (isOntimeEvent(newEvent)) { - (newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown); - } else if (isOntimeDelay(newEvent)) { - // blocks have no reason to change the rundown, from delays we need to recalculate - newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown); + const newRundown = reorderArray(persistedRundown, from, to); + for (let i = from; i <= to; i++) { + const event = newRundown.at(i); + if (isOntimeEvent(event)) { + event.revision += 1; } + } + return { newRundown, newEvent: newRundown.at(from) }; +} - runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown); +type ApplyDelayArgs = MutationParams<{ eventId: string }>; +export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn { + const newRundown = apply(eventId, persistedRundown); + return { newRundown }; +} + +type SwapArgs = MutationParams<{ fromId: string; toId: string }>; +export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn { + const indexA = persistedRundown.findIndex((event) => event.id === fromId); + const eventA = persistedRundown.at(indexA); + + const indexB = persistedRundown.findIndex((event) => event.id === toId); + const eventB = persistedRundown.at(indexB); + + if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { + throw new Error('Swap only available for OntimeEvents'); } - // we need to delay updating this to ensure edit operation happens on same dataset - await DataProvider.setRundown(updatedRundown); + const { newA, newB } = swapEventData(eventA, eventB); + const newRundown = [...persistedRundown]; - rundownRevision++; + newRundown[indexA] = newA; + (newRundown[indexA] as OntimeEvent).revision += 1; + newRundown[indexB] = newB; + (newRundown[indexB] as OntimeEvent).revision += 1; - return newEvent; -} - -export async function cachedBatchEdit(ids: string[], patchObject: Partial) { - const cachedEdits = ids.map((id) => cachedEdit(id, patchObject)); - - await Promise.allSettled(cachedEdits); -} - -/** - * Deletes an event with given id from rundown, ensuring replication to delayed rundown cache - * @param eventId - */ -export async function cachedDelete(eventId: string) { - const eventIndex = DataProvider.getIndexOf(eventId); - let delayedRundown = getDelayedRundown(); - - if (eventIndex < 0) { - if (delayedRundown.findIndex((event) => event.id === eventId) >= 0) { - invalidateFromError(); - } - throw new Error(`Event with id ${eventId} not found`); - } - - let updatedRundown = DataProvider.getRundown(); - const eventBack = { ...updatedRundown[eventIndex] }; - updatedRundown = deleteAtIndex(eventIndex, updatedRundown); - if (eventId !== delayedRundown[eventIndex].id) { - invalidateFromError(); - } else { - delayedRundown = deleteAtIndex(eventIndex, delayedRundown); - if (isOntimeDelay(eventBack) || isOntimeBlock(eventBack)) { - // for events, we do not have to worry - // the following event, would have taken the place of the deleted event by now - delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown); - } - runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundown); - } - // we need to delay updating this to ensure edit operation happens on same dataset - await DataProvider.setRundown(updatedRundown); - - rundownRevision++; -} - -/** - * Reorders an event in the rundown, ensuring replication to delayed rundown cache - * @param eventId - * @param from - * @param to - */ -export async function cachedReorder(eventId: string, from: number, to: number) { - const indexCheck = DataProvider.getIndexOf(eventId); - if (indexCheck !== from) { - invalidateFromError(); - throw new Error('ID not found at index'); - } - - let updatedRundown = DataProvider.getRundown(); - const reorderedEvent = updatedRundown[from]; - updatedRundown = reorderArray(updatedRundown, from, to); - - const delayedRundown = getDelayedRundown(); - if (eventId !== delayedRundown[from].id) { - invalidateFromError(); - } else { - // TODO: could we be more granular about updates - // I fear we need to update both from and to, which could signify more iterations - runtimeCacheStore.invalidate(delayedRundownCacheKey); - } - - // we need to delay updating this to ensure edit operation happens on same dataset - await DataProvider.setRundown(updatedRundown); - - rundownRevision++; - - return reorderedEvent; -} - -export async function cachedClear() { - await DataProvider.clearRundown(); - runtimeCacheStore.setCached(delayedRundownCacheKey, []); - rundownRevision++; -} - -/** - * Swaps two events - * @param {string} fromEventId - * @param {string} toEventId - */ -export async function cachedSwap(fromEventId: string, toEventId: string) { - const fromEventIndex = DataProvider.getIndexOf(fromEventId); - const toEventIndex = DataProvider.getIndexOf(toEventId); - - const rundown = DataProvider.getRundown(); - const rundownToUpdate = swapOntimeEvents(rundown, fromEventIndex, toEventIndex); - - const delayedRundown = getDelayedRundown(); - const fromCachedEvent = delayedRundown.at(fromEventIndex); - const toCachedEvent = delayedRundown.at(toEventIndex); - - if (fromCachedEvent.id !== fromEventId || toCachedEvent.id !== toEventId) { - // something went wrong, we invalidate the cache - runtimeCacheStore.invalidate(delayedRundownCacheKey); - } else { - const delayedRundownToUpdate = swapOntimeEvents(delayedRundown, fromEventIndex, toEventIndex); - runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundownToUpdate); - } - - await DataProvider.setRundown(rundownToUpdate); - - rundownRevision++; -} - -export async function cachedApplyDelay(eventId: string) { - // update persisted rundown - const rundown: OntimeRundown = DataProvider.getRundown(); - const persistedRundown = applyDelay(eventId, rundown); - - const delayedRundown = getDelayedRundown(); - const cachedRundown = applyDelay(eventId, delayedRundown); - - // update - runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown); - await DataProvider.setRundown(persistedRundown); - - rundownRevision++; + return { newRundown }; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index f9b1f80bc..644840a8f 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -5,7 +5,14 @@ import { TimerService } from '../TimerService.js'; import { logger } from '../../classes/Logger.js'; import { RestorePoint } from '../RestoreService.js'; import * as runtimeState from '../../stores/runtimeState.js'; -import { findNext, findPrevious, getEventAtIndex, getEventWithCue, getEventWithId, getPlayableEvents } from '../rundown-service/RundownService.js'; +import { + findNext, + findPrevious, + getEventAtIndex, + getEventWithCue, + getEventWithId, + getPlayableEvents, +} from '../rundown-service/RundownService.js'; /** * Service manages runtime status of app @@ -142,7 +149,7 @@ class RuntimeService { const timedEvents = getPlayableEvents(); const state = runtimeState.getState(); - // TODO: return success boolean from runtimeState + // TODO: return success boolean from runtimeState, when we work with optimising integrations runtimeState.load(event, timedEvents); const success = event.id === state.eventNow?.id; diff --git a/apps/server/src/services/timerUtils.ts b/apps/server/src/services/timerUtils.ts index 695b71ce4..f0886f085 100644 --- a/apps/server/src/services/timerUtils.ts +++ b/apps/server/src/services/timerUtils.ts @@ -1,7 +1,6 @@ import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types'; -import { dayInMs } from 'ontime-utils'; +import { dayInMs, sortArrayByProperty } from 'ontime-utils'; import { RuntimeState } from '../stores/runtimeState.js'; -import { sortArrayByProperty } from '../utils/arrayUtils.js'; /** * handle events that span over midnight diff --git a/apps/server/src/setup.ts b/apps/server/src/setup.ts index 222c24c1c..404ecdfe5 100644 --- a/apps/server/src/setup.ts +++ b/apps/server/src/setup.ts @@ -39,8 +39,8 @@ const env = process.env.NODE_ENV || 'production'; export const isTest = Boolean(process.env.IS_TEST); export const environment = isTest ? 'test' : env; -export const isProduction = env === ('production' || 'docker') && !isTest; export const isDocker = env === 'docker'; +export const isProduction = isDocker || (env === 'production' && !isTest); // ================================================= // resolve path to external @@ -71,7 +71,7 @@ export const currentDirectory = dirname(__dirname); const testDbStartDirectory = isTest ? '../' : getAppDataPath(); export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external'); -//TODO: we only need one when they are all in the same folder +// TODO: we only need one when they are all in the same folder export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external'); // project files @@ -89,13 +89,14 @@ const getLastLoadedProject = () => { } }; -const lastLoadedProject = getLastLoadedProject(); +const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject(); // path to public db -export const resolveDbDirectory = join(testDbStartDirectory, isTest ? config.database.testdb : 'uploads'); +export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : 'uploads'); export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename); + export const pathToStartDb = isTest - ? join(currentDirectory, '../', config.database.testdb, config.database.filename) + ? join(currentDirectory, '..', config.database.testdb, config.database.filename) : join(currentDirectory, '/preloaded-db/', config.database.filename); // TODO: move all static files to the external directory @@ -108,7 +109,7 @@ export const pathToStartStyles = join(currentDirectory, '/external/styles/', con // path to public demo export const resolveDemoDirectory = join( externalsStartDirectory, - isProduction ? '/external/' : '', //move to external folde in production + isProduction ? '/external/' : '', // move to external folder in production config.demo.directory, ); export const resolveDemoPath = config.demo.filename.map((file) => { diff --git a/apps/server/src/stores/__tests__/cachingStore.test.ts b/apps/server/src/stores/__tests__/cachingStore.test.ts deleted file mode 100644 index 6f91878a0..000000000 --- a/apps/server/src/stores/__tests__/cachingStore.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { runtimeCacheStore } from '../cachingStore.js'; - -describe('cachingStore()', () => { - beforeEach(() => { - runtimeCacheStore.clear(); // Clear the cache before each test - }); - - it('should check if an item is cached', () => { - // Add an item to the cache - runtimeCacheStore.setCached('key', 'value'); - - // Check if the item is cached - expect(runtimeCacheStore.checkCached('key')).toBe(true); - expect(runtimeCacheStore.checkCached('non-existent-key')).toBe(false); - }); - - it('should get an item from the cache', () => { - // Add an item to the cache - runtimeCacheStore.setCached('key', 'value'); - - // Get the item from the cache - const result = runtimeCacheStore.getCached('key', () => 'default-value'); - - // Check the returned value - expect(result).toBe('value'); - }); - - it('should retrieve default value when item is not cached', () => { - // Get an item that is not in the cache - const result = runtimeCacheStore.getCached('non-existent-key', () => 'default-value'); - - // Check the returned value - expect(result).toBe('default-value'); - }); - - it('should set an item in the cache', () => { - // Set an item in the cache - runtimeCacheStore.setCached('key', 'value'); - - // Check if the item is cached - expect(runtimeCacheStore.checkCached('key')).toBe(true); - }); - - it('should invalidate an item in the cache', () => { - // Add an item to the cache - runtimeCacheStore.setCached('key', 'value'); - - // Invalidate the item - runtimeCacheStore.invalidate('key'); - - // Check if the item is no longer cached - expect(runtimeCacheStore.checkCached('key')).toBe(false); - }); - - it('should clear the cache', () => { - // Add items to the cache - runtimeCacheStore.setCached('key1', 'value1'); - runtimeCacheStore.setCached('key2', 'value2'); - - // Clear the cache - runtimeCacheStore.clear(); - - // Check if the cache is empty - expect(runtimeCacheStore.checkCached('key1')).toBe(false); - expect(runtimeCacheStore.checkCached('key2')).toBe(false); - }); -}); diff --git a/apps/server/src/stores/cachingStore.ts b/apps/server/src/stores/cachingStore.ts deleted file mode 100644 index c9ac5b34d..000000000 --- a/apps/server/src/stores/cachingStore.ts +++ /dev/null @@ -1,47 +0,0 @@ -interface CacheData { - data: unknown; -} - -const runtimeCache: Map = new Map(); - -export function checkCached(key: string): boolean { - return runtimeCache.has(key); -} - -export function getCached(key: string, callback: () => T): T { - if (!runtimeCache.has(key)) { - try { - const data = callback(); - runtimeCache.set(key, { data }); - } catch (error) { - console.error(`Failed retrieving data from callback: ${error}`); - } - } - - return runtimeCache.get(key).data as T; -} - -export function setCached(key: string, value: T): T { - runtimeCache.set(key, { data: value }); - return value; -} - -export function invalidate(key: string) { - runtimeCache.delete(key); -} - -export function clear() { - runtimeCache.clear(); -} - -function createCacheStore() { - return { - checkCached, - getCached, - setCached, - invalidate, - clear, - }; -} - -export const runtimeCacheStore = createCacheStore(); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index c0c811170..21eea817e 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -371,7 +371,8 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial { await page.getByRole('button', { name: 'Import' }).click(); // asset test events - await page.getByPlaceholder('Start').first().click(); - await page.getByText('First test event').click(); - await page.getByTestId('delay-input').click(); - await page.getByText('New start: 10:10').click(); - await page.getByText('Second test event').click(); - await page.getByText('Lunch').click(); - await page.getByText('Third test event').click(); + await page.getByText('Albania').click(); + await page.getByText('Latvia').click(); + await page.getByText('Lithuania').click(); }); diff --git a/e2e/tests/features/202-cuesheet.spec.ts b/e2e/tests/features/202-cuesheet.spec.ts index 14b6b4fc6..7f937e2b8 100644 --- a/e2e/tests/features/202-cuesheet.spec.ts +++ b/e2e/tests/features/202-cuesheet.spec.ts @@ -2,31 +2,21 @@ import { expect, test } from '@playwright/test'; import fs from 'fs'; test('cuesheet displays events and exports csv', async ({ page }) => { - // ensure elements exist in editor - await page.goto('http://localhost:4001/editor'); - await page.getByText('First test event').click(); - await page.getByText('Second test event').click(); - await page.getByText('Third test event').click(); - await page.getByText('Add timeSubtract timeApplyCancel').click(); - await page.getByText('Lunch').click(); - // same elements in cuesheet await page.goto('http://localhost:4001/cuesheet'); - await page.getByText('All about Carlos demo event').click(); - await page.getByRole('cell', { name: 'First test event' }).click(); - await page.getByRole('cell', { name: 'Second test event' }).click(); - await page.getByRole('cell', { name: 'Third test event' }).click(); - await page.getByRole('cell', { name: '+10 min' }).click(); - await page.getByRole('cell', { name: 'Lunch' }).click(); - const downloadPromise = page.waitForEvent('download'); - await page.getByTestId('cuesheet').getByText('Export').click(); - await page.getByText('CSV').click(); + await page.getByText('Eurovision Song Contest').click(); + await page.getByRole('cell', { name: 'Lunch break' }).click(); + await page.getByRole('cell', { name: 'Albania' }).click(); + await page.getByRole('cell', { name: 'Latvia' }).click(); + await page.getByRole('cell', { name: 'Lithuania' }).click(); // From here we test the CSV download feature + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('cuesheet').getByText('Export CSV').click(); - function validateCSV(contents) { + function validateCSV(contents: string) { // We should try to keep this in sync with the implementation over at cuesheetUtils.ts - const expectedHeader = ['All about Carlos demo event', 'www.getontime.no']; + const expectedHeader = ['Project title: Eurovision Song Contest', 'Project description: Turin 2022']; const expectedColumns = [ 'Time Start', 'Time End', @@ -50,14 +40,15 @@ test('cuesheet displays events and exports csv', async ({ page }) => { 'user8', 'user9', ]; - const expectedValues = ['First test event', 'Second test event', 'Third test event', 'Lunch']; + const expectedValues = ['Albania', 'Latvia', 'Lithuania', 'Lunch break']; const allExpected = [...expectedHeader, ...expectedColumns, ...expectedValues]; - return allExpected.every((value) => contents.includes(value)); + return allExpected.every((value) => { + return contents.toLowerCase().includes(value.toLowerCase()); + }); } const download = await downloadPromise; const contents = await fs.promises.readFile(await download.path(), 'utf-8'); - expect(contents).toContain('All about Carlos demo event'); expect(validateCSV(contents)).toBe(true); }); diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json index 66186be3b..6bbc1d0ea 100644 --- a/e2e/tests/fixtures/test-db.json +++ b/e2e/tests/fixtures/test-db.json @@ -1,48 +1,15 @@ { "rundown": [ { - "title": "First test event", - "subtitle": "", - "presenter": "", - "note": "", - "endAction": "none", - "timerType": "count-down", - "timeStart": 32400000, - "timeEnd": 36000000, - "duration": 3600000, - "isPublic": true, - "skip": false, - "colour": "", - "user0": "", - "user1": "", - "user2": "", - "user3": "", - "user4": "", - "user5": "", - "user6": "", - "user7": "", - "user8": "", - "user9": "", - "type": "event", - "revision": 4, - "id": "aa42f" - }, - { - "duration": 600000, - "type": "delay", - "revision": 0, - "id": "b1d5a" - }, - { - "title": "Second test event", - "subtitle": "", - "presenter": "", - "note": "", + "title": "Albania", + "subtitle": "Sekret", + "presenter": "Ronela Hajati", + "note": "SF1.01", "endAction": "none", "timerType": "count-down", "timeStart": 36000000, - "timeEnd": 39600000, - "duration": 3600000, + "timeEnd": 37200000, + "duration": 1200000, "isPublic": true, "skip": false, "colour": "", @@ -57,24 +24,20 @@ "user8": "", "user9": "", "type": "event", - "revision": 2, - "id": "d71bc" + "revision": 0, + "id": "32d31", + "cue": "SF1.01" }, { - "title": "Lunch", - "type": "block", - "id": "91682" - }, - { - "title": "Third test event", - "subtitle": "", - "presenter": "", - "note": "", + "title": "Latvia", + "subtitle": "Eat Your Salad", + "presenter": "Citi Zeni", + "note": "SF1.02", "endAction": "none", "timerType": "count-down", - "timeStart": 39600000, - "timeEnd": 720000, - "duration": 47520000, + "timeStart": 37500000, + "timeEnd": 38700000, + "duration": 1200000, "isPublic": true, "skip": false, "colour": "", @@ -89,17 +52,364 @@ "user8": "", "user9": "", "type": "event", - "revision": 2, - "id": "da5b4" + "revision": 0, + "id": "21cd2", + "cue": "SF1.02" + }, + { + "title": "Lithuania", + "subtitle": "Sentimentai", + "presenter": "Monika Liu", + "note": "SF1.03", + "endAction": "none", + "timerType": "count-down", + "timeStart": 39000000, + "timeEnd": 40200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "0b371", + "cue": "SF1.03" + }, + { + "title": "Switzerland", + "subtitle": "Boys Do Cry", + "presenter": "Marius Bear", + "note": "SF1.04", + "endAction": "none", + "timerType": "count-down", + "timeStart": 40500000, + "timeEnd": 41700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "3cd28", + "cue": "SF1.04" + }, + { + "title": "Slovenia", + "subtitle": "Disko", + "presenter": "LPS", + "note": "SF1.05", + "endAction": "none", + "timerType": "count-down", + "timeStart": 42000000, + "timeEnd": 43200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "e457f", + "cue": "SF1.05" + }, + { + "title": "Lunch break", + "type": "block", + "id": "01e85" + }, + { + "title": "Ukraine", + "subtitle": "Stefania", + "presenter": "Kalush Orchestra", + "note": "SF1.06", + "endAction": "none", + "timerType": "count-down", + "timeStart": 47100000, + "timeEnd": 48300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "1c420", + "cue": "SF1.06" + }, + { + "title": "Bulgaria", + "subtitle": "Intention", + "presenter": "Intelligent Music Project", + "note": "SF1.07", + "endAction": "none", + "timerType": "count-down", + "timeStart": 48600000, + "timeEnd": 49800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "b7737", + "cue": "SF1.07" + }, + { + "title": "Netherlands", + "subtitle": "De Diepte", + "presenter": "S10", + "note": "SF1.08", + "endAction": "none", + "timerType": "count-down", + "timeStart": 50100000, + "timeEnd": 51300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "d3a80", + "cue": "SF1.08" + }, + { + "title": "Moldova", + "subtitle": "Trenuletul", + "presenter": "Zdob si Zdub", + "note": "SF1.09", + "endAction": "none", + "timerType": "count-down", + "timeStart": 51600000, + "timeEnd": 52800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "8276c", + "cue": "SF1.09" + }, + { + "title": "Portugal", + "subtitle": "Saudade Saudade", + "presenter": "Maro", + "note": "SF1.10", + "endAction": "none", + "timerType": "count-down", + "timeStart": 53100000, + "timeEnd": 54300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "2340b", + "cue": "SF1.10" + }, + { + "title": "Afternoon break", + "type": "block", + "id": "cb90b" + }, + { + "title": "Croatia", + "subtitle": "Guilty Pleasure", + "presenter": "Mia Dimsic", + "note": "SF1.11", + "endAction": "none", + "timerType": "count-down", + "timeStart": 56100000, + "timeEnd": 57300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "503c4", + "cue": "SF1.11" + }, + { + "title": "Denmark", + "subtitle": "The Show", + "presenter": "Reddi", + "note": "SF1.12", + "endAction": "none", + "timerType": "count-down", + "timeStart": 57600000, + "timeEnd": 58800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "5e965", + "cue": "SF1.12" + }, + { + "title": "Austria", + "subtitle": "Halo", + "presenter": "LUM!X & Pia Maria", + "note": "SF1.13", + "endAction": "none", + "timerType": "count-down", + "timeStart": 59100000, + "timeEnd": 60300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "bab4a", + "cue": "SF1.13" + }, + { + "title": "Greece", + "subtitle": "Die Together", + "presenter": "Amanda Tenfjord", + "note": "SF1.14", + "endAction": "none", + "timerType": "count-down", + "timeStart": 60600000, + "timeEnd": 61800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "user0": "", + "user1": "", + "user2": "", + "user3": "", + "user4": "", + "user5": "", + "user6": "", + "user7": "", + "user8": "", + "user9": "", + "type": "event", + "revision": 0, + "id": "d3eb1", + "cue": "SF1.14" } ], "project": { - "title": "All about Carlos demo event", - "description": "Demo event for Ontime", + "title": "Eurovision Song Contest", + "description": "Turin 2022", "publicUrl": "www.getontime.no", - "publicInfo": "WiFi: demoproject \nPassword: ontimeproject", - "backstageUrl": "www.getontime.no", - "backstageInfo": "WiFi: demobackstage\nPassword: ontimeproject" + "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" }, "settings": { "app": "ontime", @@ -112,6 +422,11 @@ }, "viewSettings": { "overrideStyles": false, + "normalColor": "#ffffffcc", + "warningColor": "#FFAB33", + "warningThreshold": 120000, + "dangerColor": "#ED3333", + "dangerThreshold": 60000, "endMessage": "" }, "aliases": [ @@ -137,8 +452,25 @@ "portIn": 8888, "portOut": 9999, "targetIP": "127.0.0.1", - "enabledIn": false, - "enabledOut": false, + "enabledIn": true, + "enabledOut": true, + "subscriptions": { + "onLoad": [], + "onStart": [], + "onPause": [], + "onStop": [], + "onUpdate": [ + { + "id": "10eea", + "enabled": true, + "message": "/ontime/update/{{timer.current}}" + } + ], + "onFinish": [] + } + }, + "http": { + "enabledOut": true, "subscriptions": { "onLoad": [], "onStart": [], diff --git a/e2e/tests/utils/uploadTestShowfile.ts b/e2e/tests/utils/uploadTestShowfile.ts deleted file mode 100644 index cbc5c2559..000000000 --- a/e2e/tests/utils/uploadTestShowfile.ts +++ /dev/null @@ -1,25 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -// haven't been able to get this to work -// it is well discussed in the issues, so should be able to find something -// https://playwrightsolutions.com/making-a-post/ -// https://playwright.dev/docs/api/class-apirequestcontext#api-request-context-fetch -// the upload is accepted by backend we receive 400 after upload and parse - -export async function uploadTestDb(request) { - const filePath = path.resolve('e2e/tests/fixtures/test-db.json'); - const file = fs.readFileSync(filePath); - - const response = await request.post('http://localhost:4001/ontime/db?onlyRundown=false', { - multipart: { - file: { - fileName: filePath, - mimeType: 'application/json', - buffer: file, - }, - }, - }); - - return response; -} diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index 7b5763fb6..15c7a131f 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,6 +1,10 @@ -import { OntimeRundown } from '../../definitions/core/Rundown.type.js'; +import { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js'; -export interface GetRundownCached { - rundown: OntimeRundown; +type EventId = string; +export type NormalisedRundown = Record; + +export interface RundownCached { + rundown: NormalisedRundown; + order: EventId[]; revision: number; } diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 707bd66d9..9d15a9954 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -16,7 +16,6 @@ export type OntimeBaseEvent = { export type OntimeDelay = OntimeBaseEvent & { type: SupportedEvent.Delay; duration: number; - revision: number; }; export type OntimeBlock = OntimeBaseEvent & { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b13c30938..085a5aa59 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -48,7 +48,7 @@ export type { ProjectFileListResponse, MessageResponse, } from './api/ontime-controller/BackendResponse.type.js'; -export type { GetRundownCached } from './api/rundown-controller/BackendResponse.type.js'; +export type { RundownCached, NormalisedRundown } from './api/rundown-controller/BackendResponse.type.js'; // SERVER RUNTIME export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 4b8e9ae49..ffc6d23c9 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -1,5 +1,4 @@ // runtime utils -export { getFirst, getFirstEvent, getLastEvent, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js'; export { validatePlayback } from './src/validate-action/validatePlayback.js'; export { validateTimes } from './src/validate-events/validateEvent.js'; export { calculateDuration } from './src/validate-events/validateEvent.js'; @@ -8,7 +7,23 @@ export { calculateDuration } from './src/validate-events/validateEvent.js'; 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 { getPreviousEvent, swapOntimeEvents } from './src/rundown-utils/rundownUtils.js'; +export { + getFirst, + getFirstEvent, + getFirstEventNormal, + getFirstNormal, + getLastEvent, + getLastEventNormal, + getNext, + getNextEvent, + getNextEventNormal, + getNextNormal, + getPrevious, + getPreviousEvent, + getPreviousEventNormal, + getPreviousNormal, + swapEventData, +} from './src/rundown-utils/rundownUtils.js'; // format utils export { @@ -35,6 +50,9 @@ export { dayInMs, mts } from './src/timeConstants.js'; // helpers from externals export { deepmerge } from './src/externals/deepmerge.js'; +// array utils +export { deleteAtIndex, insertAtIndex, reorderArray, sortArrayByProperty } from './src/array-utils/arrayUtils.js'; + // generic utilities export { isNumeric } from './src/types/types.js'; diff --git a/apps/server/src/utils/__tests__/arrayUtils.test.ts b/packages/utils/src/array-utils/arrayUtils.test.ts similarity index 99% rename from apps/server/src/utils/__tests__/arrayUtils.test.ts rename to packages/utils/src/array-utils/arrayUtils.test.ts index 9d88353d5..bfb363344 100644 --- a/apps/server/src/utils/__tests__/arrayUtils.test.ts +++ b/packages/utils/src/array-utils/arrayUtils.test.ts @@ -1,4 +1,4 @@ -import { insertAtIndex, reorderArray, sortArrayByProperty } from '../arrayUtils.js'; +import { insertAtIndex, reorderArray, sortArrayByProperty } from './arrayUtils.js'; describe('insertAtIndex', () => { it('should insert an item at the beginning of the array', () => { diff --git a/apps/server/src/utils/arrayUtils.ts b/packages/utils/src/array-utils/arrayUtils.ts similarity index 94% rename from apps/server/src/utils/arrayUtils.ts rename to packages/utils/src/array-utils/arrayUtils.ts index a963ebddb..1f42a230e 100644 --- a/apps/server/src/utils/arrayUtils.ts +++ b/packages/utils/src/array-utils/arrayUtils.ts @@ -58,6 +58,8 @@ export function reorderArray(array: T[], fromIndex: number, toIndex: number) export const sortArrayByProperty = (arr: T[], property: string): T[] => { return [...arr].sort((a, b) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore -- its ok return a[property] - b[property]; }); }; diff --git a/packages/utils/src/generate-id/generateId.test.ts b/packages/utils/src/generate-id/generateId.test.ts index de5badb30..bbd1f3af8 100644 --- a/packages/utils/src/generate-id/generateId.test.ts +++ b/packages/utils/src/generate-id/generateId.test.ts @@ -1,28 +1,6 @@ import { generateId } from './generateId.js'; -test('generate a valid 5 digit id', () => { +test('generate a valid 6 digit id', () => { const id = generateId(); - expect(id.length).toBe(5); -}); - -test('generate 100 with less than 110 attempts', () => { - const ids = new Set(); - let attempts = 1; - while (ids.size < 100) { - ids.add(generateId()); - attempts++; - } - - expect(attempts).toBeLessThan(105); -}); - -test('generate 1000 with less than 1020 attempts', () => { - const ids = new Set(); - let attempts = 1; - while (ids.size < 1000) { - ids.add(generateId()); - attempts++; - } - - expect(attempts).toBeLessThan(1020); + expect(id.length).toBe(6); }); diff --git a/packages/utils/src/generate-id/generateId.ts b/packages/utils/src/generate-id/generateId.ts index 131710239..99331587b 100644 --- a/packages/utils/src/generate-id/generateId.ts +++ b/packages/utils/src/generate-id/generateId.ts @@ -1,6 +1,6 @@ import { customAlphabet } from 'nanoid'; -const nanoid = customAlphabet('1234567890abcdef', 5); +const nanoid = customAlphabet('1234567890abcdef', 6); /** * Generates a random id from the defined alphabet diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index bb5cf0175..63edb57ba 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -1,6 +1,6 @@ -import { OntimeRundown, SupportedEvent } from 'ontime-types'; +import { OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; -import { getNext, getNextEvent, getPrevious, getPreviousEvent } from './rundownUtils'; +import { getNext, getNextEvent, getPrevious, getPreviousEvent, swapEventData } from './rundownUtils'; describe('getNext()', () => { it('returns the next event of type event', () => { @@ -149,3 +149,43 @@ describe('getPreviousEvent()', () => { expect(previousIndex).toBe(null); }); }); + +describe('swapEventData', () => { + it('swaps some data between two events', () => { + const eventA = { + id: '1', + cue: 'A', + timeStart: 1, + timeEnd: 1, + duration: 1, + delay: 1, + } as OntimeEvent; + const eventB = { + id: '2', + cue: 'B', + timeStart: 2, + timeEnd: 2, + duration: 2, + delay: 2, + } as OntimeEvent; + + const { newA, newB } = swapEventData(eventA, eventB); + + expect(newA).toMatchObject({ + id: '1', + cue: 'B', + timeStart: 1, + timeEnd: 1, + duration: 1, + delay: 1, + }); + expect(newB).toMatchObject({ + id: '2', + cue: 'A', + timeStart: 2, + timeEnd: 2, + duration: 2, + delay: 2, + }); + }); +}); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index a887dd0b1..be2166e41 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,4 +1,4 @@ -import { isOntimeEvent, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import { isOntimeEvent, NormalisedRundown, OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; /** * Gets first event in rundown, if it exists @@ -9,6 +9,17 @@ export function getFirst(rundown: OntimeRundownEntry[]) { return rundown.length ? rundown[0] : 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; +} + /** * Gets first scheduled event in rundown, if it exists * @param {OntimeRundownEntry[]} rundown @@ -27,6 +38,34 @@ export function getFirstEvent(rundown: OntimeRundownEntry[]): { return { firstEvent: null, firstIndex: null }; } +/** + * Gets first scheduled event in a normalised rundown, if it exists + * @param rundown + * @param order + * @returns + */ +export function getFirstEventNormal( + rundown: NormalisedRundown, + order: string[], +): { + firstEvent: OntimeEvent | null; + firstIndex: number | null; +} { + for (let i = 0; i < order.length; i++) { + const firstId = order[i]; + const firstEvent = rundown[firstId]; + if (isOntimeEvent(firstEvent)) { + return { firstEvent, firstIndex: i }; + } + } + return { firstEvent: null, firstIndex: null }; +} + +/** + * Gets last scheduled event in rundown, if it exists + * @param {OntimeRundownEntry[]} rundown + * @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } } + */ export function getLastEvent(rundown: OntimeRundown): { lastEvent: OntimeEvent | null; lastIndex: number | null; @@ -45,7 +84,34 @@ export function getLastEvent(rundown: OntimeRundown): { } /** - * Gets next event in rundown, if it exists + * Gets last scheduled event in a normalised rundown, if it exists + * @param rundown + * @param order + * @return {{ firstEvent: OntimeEvent | null; firstIndex: number | null } } + */ +export function getLastEventNormal( + rundown: NormalisedRundown, + order: string[], +): { + lastEvent: OntimeEvent | null; + lastIndex: number | null; +} { + if (order.length < 1) { + return { lastEvent: null, lastIndex: null }; + } + + for (let i = order.length - 1; i > 0; i--) { + const lastId = order[i]; + const lastEvent = rundown[lastId]; + if (isOntimeEvent(lastEvent)) { + return { lastEvent, lastIndex: i }; + } + } + return { lastEvent: null, lastIndex: null }; +} + +/** + * Gets next entry in rundown, if it exists * @param {OntimeRundownEntry[]} rundown * @param {string} currentId * @return {{ nextEvent: OntimeRundownEntry | null; nextIndex: number | null } } @@ -64,6 +130,29 @@ export function getNext( } } +/** + * Gets next entry in rundown, if it exists + * @param rundown + * @param order + * @param currentId + * @returns + */ +export function getNextNormal( + rundown: NormalisedRundown, + order: string[], + currentId: string, +): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } { + const index = order.findIndex((id) => id === currentId); + if (index !== -1 && index + 1 < order.length) { + const nextIndex = index + 1; + const nextId = order[nextIndex]; + const nextEvent = rundown[nextId]; + return { nextEvent, nextIndex }; + } else { + return { nextEvent: null, nextIndex: null }; + } +} + /** * Gets next scheduled event in rundown, if it exists * @param {OntimeRundownEntry[]} rundown @@ -89,7 +178,34 @@ export function getNextEvent( } /** - * Gets previous event in rundown, if it exists + * Gets next scheduled event in a normalised rundown, if it exists + * @param rundown + * @param order + * @param {string} currentId + * @return {{ nextEvent: OntimeEvent | null; nextIndex: number | null } } + */ +export function getNextEventNormal( + rundown: NormalisedRundown, + order: string[], + currentId: string, +): { nextEvent: OntimeEvent | null; nextIndex: number | null } { + const index = order.findIndex((id) => id === 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]; + if (isOntimeEvent(nextEvent)) { + return { nextEvent, nextIndex: i }; + } + } + return { nextEvent: null, nextIndex: null }; +} + +/** + * Gets previous entry in rundown, if it exists * @param {OntimeRundownEntry[]} rundown * @param {string} currentId * @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } } @@ -108,6 +224,29 @@ export function getPrevious( } } +/** + * Gets previous entry in a nornalised rundown, if it exists + * @param rundown + * @param order + * @param {string} currentId + * @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } } + */ +export function getPreviousNormal( + rundown: NormalisedRundown, + order: string[], + currentId: string, +): { previousEvent: OntimeRundownEntry | null; previousIndex: number | null } { + const index = order.findIndex((id) => id === currentId); + if (index !== -1 && index - 1 >= 0) { + const previousIndex = index - 1; + const previousId = order[previousIndex]; + const previousEvent = rundown[previousId]; + return { previousEvent, previousIndex }; + } else { + return { previousEvent: null, previousIndex: null }; + } +} + /** * Gets previous scheduled event in rundown, if it exists * @param {OntimeRundownEntry[]} rundown @@ -132,41 +271,54 @@ export function getPreviousEvent( } /** - * @description swaps two OntimeEvents in the rundown - * @param {OntimeRundown} rundown - * @param {number} fromEventIndex - * @param {number} toEventIndex - * @returns {OntimeRundown} + * 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 const swapOntimeEvents = ( - rundown: OntimeRundown, - fromEventIndex: number, - toEventIndex: number, -): OntimeRundown => { - const updatedRundown = [...rundown]; - - if (fromEventIndex < 0 || toEventIndex < 0) { - throw new Error('ID not found at index'); +export function getPreviousEventNormal( + rundown: NormalisedRundown, + order: string[], + currentId: string, +): { previousEvent: OntimeEvent | null; previousIndex: number | null } { + const index = order.findIndex((id) => id === 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]; + if (isOntimeEvent(previousEvent)) { + return { previousEvent, previousIndex: i }; + } + } + return { previousEvent: null, previousIndex: null }; +} - const fromEvent = updatedRundown.at(fromEventIndex) as OntimeEvent; - const toEvent = updatedRundown.at(toEventIndex) as OntimeEvent; - - updatedRundown[fromEventIndex] = { - ...toEvent, - timeStart: fromEvent.timeStart, - timeEnd: fromEvent.timeEnd, - duration: fromEvent.duration, - delay: fromEvent.delay, +/** + * @description swaps two OntimeEvents in the rundown + * @param {OntimeEvent} eventA + * @param {OntimeEvent} eventB + */ +export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => { + const newA = { + ...eventB, + id: eventA.id, + timeStart: eventA.timeStart, + timeEnd: eventA.timeEnd, + duration: eventA.duration, + delay: eventA.delay, }; - updatedRundown[toEventIndex] = { - ...fromEvent, - timeStart: toEvent.timeStart, - timeEnd: toEvent.timeEnd, - duration: toEvent.duration, - delay: toEvent.delay, + const newB = { + ...eventA, + id: eventB.id, + timeStart: eventB.timeStart, + timeEnd: eventB.timeEnd, + duration: eventB.duration, + delay: eventB.delay, }; - return updatedRundown; + return { newA, newB }; };