diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 9edf73eb5..0a3092d0d 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { makeTable } from '../../views/cuesheet/cuesheet.utils'; -import { makeCSVFromArrayOfArrays } from '../utils/csv'; +import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv'; import { apiEntryUrl } from './constants'; import { createBlob, downloadBlob } from './utils'; @@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) { export async function downloadCSV(fileName: string = 'rundown') { try { const { data, name } = await fileDownload(fileName); - const { project, rundown, customFields } = data; + const { project, rundowns, customFields } = data; + + const flatRundowns = aggregateRundowns(rundowns); + const sheetData = makeTable(project, flatRundowns, customFields); - const sheetData = makeTable(project, rundown, customFields); const fileContent = makeCSVFromArrayOfArrays(sheetData); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); diff --git a/apps/client/src/common/api/excel.ts b/apps/client/src/common/api/excel.ts index b73aa585f..5c21c987a 100644 --- a/apps/client/src/common/api/excel.ts +++ b/apps/client/src/common/api/excel.ts @@ -1,16 +1,11 @@ import axios, { AxiosResponse } from 'axios'; -import { CustomFields, OntimeRundown } from 'ontime-types'; +import { CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; const excelPath = `${apiEntryUrl}/excel`; -type PreviewSpreadsheetResponse = { - rundown: OntimeRundown; - customFields: CustomFields; -}; - /** * upload Excel file to server * @return string - file ID op the uploaded file @@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise { return response.data; } +type PreviewSpreadsheetResponse = { + rundown: Rundown; + customFields: CustomFields; +}; export async function importRundownPreview(options: ImportMap): Promise { const response: AxiosResponse = await axios.post(`${excelPath}/preview`, { options, diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 9d854eee1..99fb044d4 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,29 +1,44 @@ import axios, { AxiosResponse } from 'axios'; -import { MessageResponse, OntimeEvent, OntimeRundownEntry, RundownCached, TransientEventPayload } from 'ontime-types'; +import { + MessageResponse, + OntimeEntry, + OntimeEvent, + ProjectRundownsList, + Rundown, + TransientEventPayload, +} from 'ontime-types'; import { apiEntryUrl } from './constants'; const rundownPath = `${apiEntryUrl}/rundown`; +/** + * HTTP request to fetch a list of existing rundowns + */ +export async function fetchProjectRundownList(): Promise { + const res = await axios.get(`${rundownPath}/`); + return res.data; +} + /** * HTTP request to fetch all events */ -export async function fetchNormalisedRundown(): Promise { - const res = await axios.get(`${rundownPath}/normalised`); +export async function fetchCurrentRundown(): Promise { + const res = await axios.get(`${rundownPath}/current`); return res.data; } /** * HTTP request to post new event */ -export async function requestPostEvent(data: TransientEventPayload): Promise> { +export async function requestPostEvent(data: TransientEventPayload): Promise> { return axios.post(rundownPath, data); } /** * HTTP request to put new event */ -export async function requestPutEvent(data: Partial): Promise> { +export async function requestPutEvent(data: Partial): Promise> { return axios.put(rundownPath, data); } @@ -48,7 +63,7 @@ export type ReorderEntry = { /** * HTTP request to reorder events */ -export async function requestReorderEvent(data: ReorderEntry): Promise> { +export async function requestReorderEvent(data: ReorderEntry): Promise> { return axios.patch(`${rundownPath}/reorder`, data); } diff --git a/apps/client/src/common/api/sheets.ts b/apps/client/src/common/api/sheets.ts index ebeb9fdce..231f543db 100644 --- a/apps/client/src/common/api/sheets.ts +++ b/apps/client/src/common/api/sheets.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { apiEntryUrl } from './constants'; @@ -54,7 +54,7 @@ export const previewRundown = async ( sheetId: string, options: ImportMap, ): Promise<{ - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; }> => { const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); diff --git a/apps/client/src/common/hooks-query/useRundown.ts b/apps/client/src/common/hooks-query/useRundown.ts index 67629d4d9..9d360df0a 100644 --- a/apps/client/src/common/hooks-query/useRundown.ts +++ b/apps/client/src/common/hooks-query/useRundown.ts @@ -1,23 +1,29 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { NormalisedRundown, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { OntimeEntry, Rundown } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { RUNDOWN } from '../api/constants'; -import { fetchNormalisedRundown } from '../api/rundown'; +import { fetchCurrentRundown } from '../api/rundown'; import useProjectData from './useProjectData'; // revision is -1 so that the remote revision is higher -const cachedRundownPlaceholder = { order: [] as string[], rundown: {} as NormalisedRundown, revision: -1 }; +const cachedRundownPlaceholder: Rundown = { + id: 'default', + title: '', + order: [], + entries: {}, + revision: -1, +}; /** * Normalised rundown data */ export default function useRundown() { - const { data, status, isError, refetch, isFetching } = useQuery({ + const { data, status, isError, refetch, isFetching } = useQuery({ queryKey: RUNDOWN, - queryFn: fetchNormalisedRundown, + queryFn: fetchCurrentRundown, placeholderData: (previousData, _previousQuery) => previousData, retry: 5, retryDelay: (attempt) => attempt * 2500, @@ -37,16 +43,16 @@ export function useFlatRundown() { const loadedProject = useRef(''); const [prevRevision, setPrevRevision] = useState(-1); - const [flatRunDown, setFlatRunDown] = useState([]); + const [flatRundown, setFlatRundown] = useState([]); // update data whenever the revision changes useEffect(() => { if (data.revision !== -1 && data.revision !== prevRevision) { - const flatRundown = data.order.map((id) => data.rundown[id]); - setFlatRunDown(flatRundown); + const flatRundown = data.order.map((id) => data.entries[id]); + setFlatRundown(flatRundown); setPrevRevision(data.revision); } - }, [data.order, data.revision, data.rundown, prevRevision]); + }, [data.entries, data.order, data.revision, prevRevision]); // TODO: should we have a project id field? // invalidate current version if project changes @@ -57,13 +63,13 @@ export function useFlatRundown() { } }, [projectData]); - return { data: flatRunDown, status }; + return { data: flatRundown, status }; } /** * Provides access to a partial rundown based on a filter callback */ -export function usePartialRundown(cb: (event: OntimeRundownEntry) => boolean) { +export function usePartialRundown(cb: (event: OntimeEntry) => boolean) { const { data, status } = useFlatRundown(); const filteredData = useMemo(() => { return data.filter(cb); diff --git a/apps/client/src/common/hooks/useEventAction.ts b/apps/client/src/common/hooks/useEventAction.ts index 3ebe479e5..e2e8b1064 100644 --- a/apps/client/src/common/hooks/useEventAction.ts +++ b/apps/client/src/common/hooks/useEventAction.ts @@ -2,11 +2,12 @@ import { useCallback } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { isOntimeEvent, + MaybeString, OntimeBlock, OntimeDelay, + OntimeEntry, OntimeEvent, - OntimeRundownEntry, - RundownCached, + Rundown, TimeField, TimeStrategy, TransientEventPayload, @@ -31,12 +32,12 @@ import { useEditorSettings } from '../stores/editorSettings'; export type EventOptions = Partial<{ // options to any new block (event / delay / block) - after: string; - before: string; + after: MaybeString; + before: MaybeString; // options to blocks of type OntimeEvent defaultPublic: boolean; linkPrevious: boolean; - lastEventId: string; + lastEventId: MaybeString; }>; /** @@ -57,11 +58,11 @@ export const useEventAction = () => { const getEventById = useCallback( (eventId: string) => { - const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.rundown) { + const cachedRundown = queryClient.getQueryData(RUNDOWN); + if (!cachedRundown?.entries) { return; } - return cachedRundown.rundown[eventId]; + return cachedRundown.entries[eventId]; }, [queryClient], ); @@ -100,9 +101,8 @@ export const useEventAction = () => { newEvent.linkStart = applicationOptions.lastEventId; } else if (applicationOptions?.lastEventId) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value - const rundownData = queryClient.getQueryData(RUNDOWN)!; - const { rundown } = rundownData; - const previousEvent = rundown[applicationOptions.lastEventId]; + const rundownData = queryClient.getQueryData(RUNDOWN)!; + const previousEvent = rundownData.entries[applicationOptions.lastEventId]; if (isOntimeEvent(previousEvent)) { newEvent.timeStart = previousEvent.timeEnd; } @@ -180,15 +180,21 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); const eventId = newEvent.id; if (previousData && eventId) { // optimistically update object - const newRundown = { ...previousData.rundown }; + const newRundown = { ...previousData.entries }; // @ts-expect-error -- we expect the events to be of same type newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; - queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: previousData.order, + entries: newRundown, + revision: -1, + }); } // Return a context with the previous and new events @@ -196,7 +202,7 @@ export const useEventAction = () => { }, // Mutation fails, rollback undoes optimist update onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -210,7 +216,7 @@ export const useEventAction = () => { * Updates existing event */ const updateEvent = useCallback( - async (event: Partial) => { + async (event: Partial) => { try { await _updateEventMutation.mutateAsync(event); } catch (error) { @@ -296,9 +302,9 @@ export const useEventAction = () => { * Utility function to get the previous event end time */ function getPreviousEnd(): number { - const cachedRundown = queryClient.getQueryData(RUNDOWN); + const cachedRundown = queryClient.getQueryData(RUNDOWN); - if (!cachedRundown?.order || !cachedRundown?.rundown) { + if (!cachedRundown?.order || !cachedRundown?.entries) { return 0; } @@ -308,7 +314,7 @@ export const useEventAction = () => { } let previousEnd = 0; for (let i = index - 1; i >= 0; i--) { - const event = cachedRundown.rundown[cachedRundown.order[i]]; + const event = cachedRundown.entries[cachedRundown.order[i]]; if (isOntimeEvent(event)) { previousEnd = event.timeEnd; break; @@ -331,11 +337,11 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousEvents = queryClient.getQueryData(RUNDOWN); + const previousRundown = queryClient.getQueryData(RUNDOWN); - if (previousEvents) { + if (previousRundown) { const eventIds = new Set(ids); - const newRundown = { ...previousEvents.rundown }; + const newRundown = { ...previousRundown.entries }; eventIds.forEach((eventId) => { if (Object.hasOwn(newRundown, eventId)) { @@ -349,16 +355,22 @@ export const useEventAction = () => { } }); - queryClient.setQueryData(RUNDOWN, { order: previousEvents.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousRundown.id, + title: previousRundown.title, + order: previousRundown.order, + entries: newRundown, + revision: -1, + }); } - // Return a context with the previous and new events - return { previousEvents }; + // Return a context with the previous rundown + return { previousRundown }; }, onSettled: async () => { await queryClient.invalidateQueries({ queryKey: RUNDOWN }); }, onError: (_error, _newEvent, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousEvents); + queryClient.setQueryData(RUNDOWN, context?.previousRundown); }, networkMode: 'always', }); @@ -386,19 +398,21 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); - const newRundown = { ...previousData.rundown }; + const newRundown = { ...previousData.entries }; for (const eventId of eventIds) { delete newRundown[eventId]; } - queryClient.setQueryData(RUNDOWN, { + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, order: newOrder, - rundown: newRundown, + entries: newRundown, revision: -1, }); } @@ -409,7 +423,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -445,10 +459,16 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); // optimistically update object - queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData?.id ?? 'default', + title: previousData?.title ?? '', + entries: {}, + order: [], + revision: -1, + }); // Return a context with the previous and new events return { previousData }; @@ -456,7 +476,7 @@ export const useEventAction = () => { // Mutation fails, rollback undos optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -516,13 +536,18 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object const newOrder = reorderArray(previousData.order, data.from, data.to); - - queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: newOrder, + entries: previousData.entries, + revision: -1, + }); } // Return a context with the previous and new events @@ -531,7 +556,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure @@ -572,22 +597,28 @@ export const useEventAction = () => { await queryClient.cancelQueries({ queryKey: RUNDOWN }); // Snapshot the previous value - const previousData = queryClient.getQueryData(RUNDOWN); + const previousData = queryClient.getQueryData(RUNDOWN); if (previousData) { // optimistically update object - const newRundown = { ...previousData.rundown }; - const eventA = previousData.rundown[from]; - const eventB = previousData.rundown[to]; + const newRundown = { ...previousData.entries }; + const eventA = previousData.entries[from]; + const eventB = previousData.entries[to]; if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { return; } - const { newA, newB } = swapEventData(eventA, eventB); + const [newA, newB] = swapEventData(eventA, eventB); newRundown[from] = newA; newRundown[to] = newB; - queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); + queryClient.setQueryData(RUNDOWN, { + id: previousData.id, + title: previousData.title, + order: previousData.order, + entries: newRundown, + revision: -1, + }); } // Return a context with the previous events @@ -596,7 +627,7 @@ export const useEventAction = () => { // Mutation fails, rollback undoes optimist update onError: (_error, _eventId, context) => { - queryClient.setQueryData(RUNDOWN, context?.previousData); + queryClient.setQueryData(RUNDOWN, context?.previousData); }, // Mutation finished, failed or successful // Fetch anyway, just to be sure diff --git a/apps/client/src/common/utils/__tests__/csv.test.ts b/apps/client/src/common/utils/__tests__/csv.test.ts index 13bd6b1b5..837b0a0cb 100644 --- a/apps/client/src/common/utils/__tests__/csv.test.ts +++ b/apps/client/src/common/utils/__tests__/csv.test.ts @@ -1,4 +1,6 @@ -import { makeCSVFromArrayOfArrays } from '../csv'; +import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types'; + +import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv'; describe('makeCSVFromArrayOfArrays()', () => { it('joins an array of arrays with commas and newlines', () => { @@ -11,3 +13,32 @@ after newline,after comma `); }); }); + +describe('aggregateRundowns()', () => { + it('flattens an object of rundowns into a single array', () => { + const rundowns = { + first: { + id: '', + title: '', + revision: 0, + order: ['1', '2'], + entries: { + '1': { id: '1' } as OntimeEntry, + '2': { id: '2' } as OntimeEntry, + }, + }, + second: { + id: '', + title: '', + revision: 0, + order: ['3', '4'], + entries: { + '3': { id: '3' } as OntimeEntry, + '4': { id: '4' } as OntimeEntry, + }, + } as Rundown, + } as ProjectRundowns; + + expect(aggregateRundowns(rundowns)).toStrictEqual([{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]); + }); +}); diff --git a/apps/client/src/common/utils/__tests__/eventsManager.test.ts b/apps/client/src/common/utils/__tests__/eventsManager.test.ts index a2781d3e3..f54b5d768 100644 --- a/apps/client/src/common/utils/__tests__/eventsManager.test.ts +++ b/apps/client/src/common/utils/__tests__/eventsManager.test.ts @@ -1,4 +1,4 @@ -import { EndAction, EventCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; +import { EndAction, EntryCustomFields, OntimeEvent, SupportedEvent, TimerType, TimeStrategy } from 'ontime-types'; import { cloneEvent } from '../eventsManager'; @@ -29,7 +29,7 @@ describe('cloneEvent()', () => { gap: 0, custom: { lighting: '3', - } as EventCustomFields, + } as EntryCustomFields, }; const cloned = cloneEvent(original); diff --git a/apps/client/src/common/utils/__tests__/urlPresets.test.ts b/apps/client/src/common/utils/__tests__/urlPresets.test.ts index 7b127d703..dbdcc8253 100644 --- a/apps/client/src/common/utils/__tests__/urlPresets.test.ts +++ b/apps/client/src/common/utils/__tests__/urlPresets.test.ts @@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => { describe('handle url sharing edge cases', () => { it('finds the correct preset when the url contains extra arguments', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy() - }) + expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy(); + }); it('appends the feature params to the alias', () => { const location = resolvePath('/demopage?locked=true&token=123'); - expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123') - }) + expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); + }); }); }); @@ -83,25 +83,26 @@ describe('generatePathFromPreset()', () => { }); test('appends the feature params to the alias', () => { - expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123'); + expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe( + 'timer?user=guest&alias=demopage&locked=true&token=123', + ); }); }); describe('arePathsEquivalent()', () => { - it("checks whether the paths match", () => { + it('checks whether the paths match', () => { expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy(); - }) + }); - it("checks whether the params match", () => { + it('checks whether the params match', () => { expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); - }) + }); - it("considers edge cases for the url sharing feature", () => { + it('considers edge cases for the url sharing feature', () => { expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); - }) + }); }); - diff --git a/apps/client/src/common/utils/csv.ts b/apps/client/src/common/utils/csv.ts index 527e7a5f2..055c0c9d6 100644 --- a/apps/client/src/common/utils/csv.ts +++ b/apps/client/src/common/utils/csv.ts @@ -1,10 +1,31 @@ import { stringify } from 'csv-stringify/browser/esm/sync'; +import { OntimeEntry, ProjectRundowns } from 'ontime-types'; /** - * @description Converts an array of arrays to a CSV file - * @param {string[][]} arrayOfArrays - * @return {string} + * Converts an array of arrays to a CSV file */ export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { return stringify(arrayOfArrays); } + +/** + * Receives an object of rundowns, and flattens them into a single, linear rundown + * Used for CSV export + */ +export function aggregateRundowns(rundowns: ProjectRundowns): OntimeEntry[] { + const rundownKeys = Object.keys(rundowns); + if (rundownKeys.length === 0) return []; + const flatRundown: OntimeEntry[] = []; + + for (const key of rundownKeys) { + const { order, entries } = rundowns[key]; + + for (let i = 0; i < order.length; i++) { + const entryId = order[i]; + const entry = entries[entryId]; + + flatRundown.push(entry); + } + } + return flatRundown; +} diff --git a/apps/client/src/common/utils/eventsManager.ts b/apps/client/src/common/utils/eventsManager.ts index 51aeac470..ad22b0820 100644 --- a/apps/client/src/common/utils/eventsManager.ts +++ b/apps/client/src/common/utils/eventsManager.ts @@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => { isPublic: event.isPublic, skip: event.skip, colour: event.colour, + currentBlock: event.currentBlock, revision: 0, delay: event.delay, // the events will be collocated, so having the same metadata is a good start dayOffset: event.dayOffset, diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index dd77cadf5..cde2d9c8a 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,4 +1,4 @@ -import { Log, RundownCached, RuntimeStore } from 'ontime-types'; +import { Log, Rundown, RuntimeStore } from 'ontime-types'; import { isProduction, websocketUrl } from '../../externals'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; @@ -203,7 +203,7 @@ export const connectSocket = () => { invalidateAllCaches(); } else if (target === 'RUNDOWN') { const { revision } = payload; - const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; + const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; if (revision > currentRevision) { ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx index 090cc1d4a..0d0926b1f 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/ReportSettings.tsx @@ -29,8 +29,8 @@ export default function ReportSettings() { }; const combinedReport = useMemo(() => { - return getCombinedReport(reportData, data.rundown, data.order); - }, [reportData, data.rundown, data.order]); + return getCombinedReport(reportData, data.entries, data.order); + }, [reportData, data.entries, data.order]); return ( diff --git a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts index 0370baa00..d47ee4f9a 100644 --- a/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts +++ b/apps/client/src/features/app-settings/panel/feature-settings-panel/reportSettings.utils.ts @@ -1,4 +1,4 @@ -import { isOntimeEvent, MaybeNumber, NormalisedRundown, OntimeReport } from 'ontime-types'; +import { EntryId, isOntimeEvent, MaybeNumber, OntimeReport, RundownEntries } from 'ontime-types'; import { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv'; import { formatTime } from '../../../../common/utils/time'; @@ -16,7 +16,7 @@ export type CombinedReport = { /** * Creates a combined report with the rundown data */ -export function getCombinedReport(report: OntimeReport, rundown: NormalisedRundown, order: string[]): CombinedReport[] { +export function getCombinedReport(report: OntimeReport, rundown: RundownEntries, order: EntryId[]): CombinedReport[] { if (Object.keys(report).length === 0) return []; if (order.length === 0) return []; diff --git a/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts index d3e6eddf9..48155a105 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts +++ b/apps/client/src/features/app-settings/panel/project-panel/project.utils.ts @@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record void; onCancel: () => void; @@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) { const applyImport = async () => { setLoading(true); - await importRundown(rundown, customFields); + await importRundown( + { + [rundown.id]: rundown, + }, + customFields, + ); setLoading(false); onFinished(); }; diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx index 5944c446b..c75800273 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx @@ -1,6 +1,6 @@ import { Fragment } from 'react'; import { IoLink } from 'react-icons/io5'; -import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; +import { CustomFields, isOntimeBlock, isOntimeEvent, Rundown } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import Tag from '../../../../../common/components/tag/Tag'; @@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils'; import style from './PreviewRundown.module.scss'; interface PreviewRundownProps { - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; } @@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) { - {rundown.map((event) => { - if (isOntimeBlock(event)) { + {rundown.order.map((entryId) => { + const entry = rundown.entries[entryId]; + if (isOntimeBlock(entry)) { return ( - + - - {event.type} + {entry.type} - {event.title} + {entry.title} ); } - if (!isOntimeEvent(event)) { + if (!isOntimeEvent(entry)) { return null; } eventIndex += 1; - const colour = event.colour ? getAccessibleColour(event.colour) : {}; - const countToEnd = booleanToText(event.countToEnd); - const isPublic = booleanToText(event.isPublic); - const skip = booleanToText(event.skip); + const colour = entry.colour ? getAccessibleColour(entry.colour) : {}; + const countToEnd = booleanToText(entry.countToEnd); + const isPublic = booleanToText(entry.isPublic); + const skip = booleanToText(entry.skip); return ( - + {eventIndex} - {event.type} + {entry.type} - {event.cue} - {event.title} + {entry.cue} + {entry.title} - {millisToString(event.timeStart)} - {event.linkStart && } + {millisToString(entry.timeStart)} + {entry.linkStart && } - {millisToString(event.timeEnd)} - {millisToString(event.duration)} - {millisToString(event.timeWarning)} - {millisToString(event.timeDanger)} + {millisToString(entry.timeEnd)} + {millisToString(entry.duration)} + {millisToString(entry.timeWarning)} + {millisToString(entry.timeDanger)} {countToEnd && {countToEnd}} {isPublic && {isPublic}} {skip && {skip}} - {event.colour} + {entry.colour} - {event.timerType} + {entry.timerType} - {event.endAction} + {entry.endAction} - {isOntimeEvent(event) && + {isOntimeEvent(entry) && fieldKeys.map((field) => { let value = ''; - if (field in event.custom) { - value = event.custom[field]; + if (field in entry.custom) { + value = entry.custom[field]; } return {value}; })} - {event.id} + {entry.id} - {event.note && ( + {entry.note && ( - Note: {event.note} + Note: {entry.note} )} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts index 29198eac0..3cc93c362 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useGoogleSheet.ts @@ -1,5 +1,5 @@ import { useQueryClient } from '@tanstack/react-query'; -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, ProjectRundowns } from 'ontime-types'; import { ImportMap } from 'ontime-utils'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; @@ -75,9 +75,9 @@ export default function useGoogleSheet() { }; /** applies rundown and customFields to current project */ - const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { + const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => { try { - await patchData({ rundown, customFields }); + await patchData({ rundowns, customFields }); // we are unable to optimistically set the rundown since we need // it to be normalised await queryClient.invalidateQueries({ diff --git a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts index efed9ce86..c2e49ae54 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/useSheetStore.ts @@ -1,4 +1,4 @@ -import { AuthenticationStatus, CustomFields, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, Rundown } from 'ontime-types'; import { defaultImportMap, ImportMap } from 'ontime-utils'; import { create } from 'zustand'; @@ -15,8 +15,8 @@ type SheetStore = { setAuthenticationStatus: (status: AuthenticationStatus) => void; // we get this from a preview response - rundown: OntimeRundown | null; - setRundown: (rundown: OntimeRundown | null) => void; + rundown: Rundown | null; + setRundown: (rundown: Rundown | null) => void; // we get this from a preview response customFields: CustomFields | null; @@ -60,7 +60,7 @@ export const useSheetStore = create((set, get) => ({ setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), - setRundown: (rundown: OntimeRundown | null) => set({ rundown }), + setRundown: (rundown: Rundown | null) => set({ rundown }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }), diff --git a/apps/client/src/features/operator/Operator.tsx b/apps/client/src/features/operator/Operator.tsx index 6a1c4613b..e7e849208 100644 --- a/apps/client/src/features/operator/Operator.tsx +++ b/apps/client/src/features/operator/Operator.tsx @@ -126,8 +126,8 @@ export default function Operator() { let isPast = Boolean(featureData.selectedEventId); const hidePast = isStringBoolean(searchParams.get('hidepast')); - const { firstEvent } = getFirstEventNormal(data.rundown, data.order); - const { lastEvent } = getLastEventNormal(data.rundown, data.order); + const { firstEvent } = getFirstEventNormal(data.entries, data.order); + const { lastEvent } = getLastEventNormal(data.entries, data.order); return (
@@ -152,7 +152,7 @@ export default function Operator() {
{data.order.map((eventId) => { - const entry = data.rundown[eventId]; + const entry = data.entries[eventId]; if (isOntimeEvent(entry)) { const isSelected = featureData.selectedEventId === entry.id; if (isSelected) { diff --git a/apps/client/src/features/rundown/Rundown.tsx b/apps/client/src/features/rundown/Rundown.tsx index 0d3cfc49f..392f9c4ae 100644 --- a/apps/client/src/features/rundown/Rundown.tsx +++ b/apps/client/src/features/rundown/Rundown.tsx @@ -1,15 +1,16 @@ import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; -import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useHotkeys } from '@mantine/hooks'; import { + type EntryId, + type MaybeString, + type PlayableEvent, + type Rundown, isOntimeBlock, isOntimeEvent, isPlayableEvent, - MaybeString, - PlayableEvent, Playback, - RundownCached, SupportedEvent, } from 'ontime-types'; import { @@ -21,6 +22,7 @@ import { getPreviousBlockNormal, getPreviousNormal, isNewLatest, + reorderArray, } from 'ontime-utils'; import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; @@ -39,12 +41,12 @@ import style from './Rundown.module.scss'; const RundownEntry = lazy(() => import('./RundownEntry')); interface RundownProps { - data: RundownCached; + data: Rundown; } export default function Rundown({ data }: RundownProps) { - const { order, rundown } = data; - const [statefulEntries, setStatefulEntries] = useState(order); + const { order, entries } = data; + const [statefulEntries, setStatefulEntries] = useState(order); const featureData = useRundownEditor(); const { addEvent, reorderEvent, deleteEvent } = useEventAction(); @@ -65,30 +67,30 @@ export default function Rundown({ data }: RundownProps) { const deleteAtCursor = useCallback( (cursor: string | null) => { if (!cursor) return; - const { entry, index } = getPreviousNormal(rundown, order, cursor); + const { entry, index } = getPreviousNormal(entries, order, cursor); deleteEvent([cursor]); if (entry && index !== null) { setSelectedEvents({ id: entry.id, selectMode: 'click', index }); } }, - [rundown, order, deleteEvent, setSelectedEvents], + [entries, order, deleteEvent, setSelectedEvents], ); const insertCopyAtId = useCallback( (atId: string | null, copyId: string | null, above = false) => { - const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId; + const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId; if (copyId === null) { // we cant clone without selection return; } - const cloneEntry = rundown[copyId]; + const cloneEntry = entries[copyId]; if (cloneEntry?.type === SupportedEvent.Event) { //if we don't have a cursor add the new event on top const newEvent = cloneEvent(cloneEntry); addEvent(newEvent, { after: adjustedCursor ?? undefined }); } }, - [addEvent, order, rundown], + [addEvent, order, entries], ); const insertAtId = useCallback( @@ -124,7 +126,7 @@ export default function Rundown({ data }: RundownProps) { let newCursor = cursor; if (cursor === null) { // there is no cursor, we select the first or last depending on direction - const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); if (isOntimeBlock(selected)) { setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); @@ -140,14 +142,14 @@ export default function Rundown({ data }: RundownProps) { // otherwise we select the next or previous const selected = direction === 'up' - ? getPreviousBlockNormal(rundown, order, newCursor) - : getNextBlockNormal(rundown, order, newCursor); + ? getPreviousBlockNormal(entries, order, newCursor) + : getNextBlockNormal(entries, order, newCursor); if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); } }, - [order, rundown, setSelectedEvents], + [order, entries, setSelectedEvents], ); const selectEntry = useCallback( @@ -158,7 +160,7 @@ export default function Rundown({ data }: RundownProps) { if (cursor === null) { // there is no cursor, we select the first or last depending on direction if it exists - const selected = direction === 'up' ? getLastNormal(rundown, order) : getFirstNormal(rundown, order); + const selected = direction === 'up' ? getLastNormal(entries, order) : getFirstNormal(entries, order); if (selected !== null) { setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); } @@ -167,13 +169,13 @@ export default function Rundown({ data }: RundownProps) { // otherwise we select the next or previous const selected = - direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); if (selected.entry !== null && selected.index !== null) { setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); } }, - [order, rundown, setSelectedEvents], + [order, entries, setSelectedEvents], ); const moveEntry = useCallback( @@ -182,14 +184,14 @@ export default function Rundown({ data }: RundownProps) { return; } const { index } = - direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor); + direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor); if (index !== null) { const offsetIndex = direction === 'up' ? index + 1 : index - 1; reorderEvent(cursor, offsetIndex, index); } }, - [order, reorderEvent, rundown], + [order, reorderEvent, entries], ); // shortcuts @@ -238,6 +240,9 @@ export default function Rundown({ data }: RundownProps) { setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]); + /** + * On drag end, we reorder the events + */ const handleOnDragEnd = (event: DragEndEvent) => { const { active, over } = event; @@ -245,9 +250,10 @@ export default function Rundown({ data }: RundownProps) { if (active.id !== over?.id) { const fromIndex = active.data.current?.sortable.index; const toIndex = over.data.current?.sortable.index; - // ugly hack to handle inconsistencies between dnd-kit and async store updates + + // we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates setStatefulEntries((currentEntries) => { - return arrayMove(currentEntries, fromIndex, toIndex); + return reorderArray(currentEntries, fromIndex, toIndex); }); reorderEvent(String(active.id), fromIndex, toIndex); } @@ -259,11 +265,11 @@ export default function Rundown({ data }: RundownProps) { } // last event is used to calculate relative timings - let lastEvent: PlayableEvent | undefined; // used by indicators - let thisEvent: PlayableEvent | undefined; + let lastEvent: PlayableEvent | null = null; // used by indicators + let thisEvent: PlayableEvent | null = null; // previous entry is used to infer position in the rundown for new events - let previousEntryId: string | undefined; - let thisId = previousEntryId; + let previousEntryId: MaybeString = null; + let thisId: MaybeString = null; let eventIndex = 0; // all events before the current selected are in the past @@ -272,6 +278,7 @@ export default function Rundown({ data }: RundownProps) { let totalGap = 0; const isEditMode = appMode === AppMode.Edit; let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event + return (
@@ -281,7 +288,7 @@ export default function Rundown({ data }: RundownProps) { // we iterate through a stateful copy of order to make the operations smoother // this means that this can be out of sync with order until the useEffect runs // instead of writing all the logic guards, we simply short circuit rendering here - const entry = rundown[entryId]; + const entry = entries[entryId]; if (!entry) { return null; } diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 6deb859dd..70153796a 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -1,5 +1,14 @@ import { useCallback } from 'react'; -import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types'; +import { + isOntimeBlock, + isOntimeDelay, + isOntimeEvent, + MaybeString, + OntimeEntry, + OntimeEvent, + Playback, + SupportedEvent, +} from 'ontime-types'; import { useEventAction } from '../../common/hooks/useEventAction'; import useMemoisedFn from '../../common/hooks/useMemoisedFn'; @@ -28,13 +37,13 @@ export type EventItemActions = interface RundownEntryProps { type: SupportedEvent; isPast: boolean; - data: OntimeRundownEntry; + data: OntimeEntry; loaded: boolean; eventIndex: number; hasCursor: boolean; isNext: boolean; isNextDay: boolean; - previousEntryId?: string; + previousEntryId: MaybeString; previousEventId?: string; playback?: Playback; // we only care about this if this event is playing isRolling: boolean; // we need to know even if not related to this event @@ -150,7 +159,7 @@ export default function RundownEntry(props: RundownEntryProps) { } }); - if (data.type === SupportedEvent.Event) { + if (isOntimeEvent(data)) { return ( ); - } else if (data.type === SupportedEvent.Block) { - return actionHandler('delete')} />; - } else if (data.type === SupportedEvent.Delay) { + } else if (isOntimeBlock(data)) { + return ( + + {data.events.map((eventId) => { + return
{eventId}
; + })} +
+ ); + } else if (isOntimeDelay(data)) { return ; } return null; diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss index e645de0f3..b5d6345a6 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.module.scss +++ b/apps/client/src/features/rundown/block-block/BlockBlock.module.scss @@ -22,7 +22,3 @@ .drag { @include drag-style; } - -.actionMenu { - justify-self: flex-end; -} diff --git a/apps/client/src/features/rundown/block-block/BlockBlock.tsx b/apps/client/src/features/rundown/block-block/BlockBlock.tsx index a9e18753a..5f3d0abad 100644 --- a/apps/client/src/features/rundown/block-block/BlockBlock.tsx +++ b/apps/client/src/features/rundown/block-block/BlockBlock.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { PropsWithChildren, useRef } from 'react'; import { IoReorderTwo } from 'react-icons/io5'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -7,18 +7,15 @@ import { OntimeBlock } from 'ontime-types'; import { cx } from '../../../common/utils/styleUtils'; import EditableBlockTitle from '../common/EditableBlockTitle'; -import BlockDelete from './BlockDelete'; - import style from './BlockBlock.module.scss'; interface BlockBlockProps { data: OntimeBlock; hasCursor: boolean; - onDelete: () => void; } -export default function BlockBlock(props: BlockBlockProps) { - const { data, hasCursor, onDelete } = props; +export default function BlockBlock(props: PropsWithChildren) { + const { data, hasCursor, children } = props; const handleRef = useRef(null); @@ -46,7 +43,8 @@ export default function BlockBlock(props: BlockBlockProps) { - + +
{children}
); } diff --git a/apps/client/src/features/rundown/block-block/BlockDelete.tsx b/apps/client/src/features/rundown/block-block/BlockDelete.tsx deleted file mode 100644 index 7fbe6bac5..000000000 --- a/apps/client/src/features/rundown/block-block/BlockDelete.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { IoTrash } from 'react-icons/io5'; -import { IconButton } from '@chakra-ui/react'; - -import { AppMode, useAppMode } from '../../../common/stores/appModeStore'; - -interface BlockDeleteProps { - onDelete: () => void; -} - -export default function BlockDelete(props: BlockDeleteProps) { - const { onDelete } = props; - const mode = useAppMode((state) => state.mode); - - const isRunMode = mode === AppMode.Run; - - return ( - } - variant='ontime-subtle' - color='#FA5656' - onClick={onDelete} - isDisabled={isRunMode} - /> - ); -} diff --git a/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx index 8d92efc33..acceaeef9 100644 --- a/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/CuesheetEventEditor.tsx @@ -15,23 +15,22 @@ interface CuesheetEventEditorProps { export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { const { eventId } = props; const { data } = useRundown(); - const { order, rundown } = data; const [event, setEvent] = useState(null); useEffect(() => { - if (order.length === 0) { + if (data.order.length === 0) { setEvent(null); return; } - const event = rundown[eventId]; + const event = data.entries[eventId]; if (event && isOntimeEvent(event)) { setEvent(event); } else { setEvent(null); } - }, [data, eventId, order, rundown]); + }, [eventId, data.order, data.entries]); if (!event) { return null; diff --git a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx index f21f5a0ed..2def76469 100644 --- a/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx +++ b/apps/client/src/features/rundown/event-editor/RundownEventEditor.tsx @@ -13,29 +13,28 @@ import style from './EventEditor.module.scss'; export default function RundownEventEditor() { const selectedEvents = useEventSelection((state) => state.selectedEvents); const { data } = useRundown(); - const { order, rundown } = data; const [event, setEvent] = useState(null); useEffect(() => { - if (order.length === 0) { + if (data.order.length === 0) { setEvent(null); return; } - const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); + const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId)); if (!selectedEventId) { setEvent(null); return; } - const event = rundown[selectedEventId]; + const event = data.entries[selectedEventId]; if (event && isOntimeEvent(event)) { setEvent(event); } else { setEvent(null); } - }, [order, rundown, selectedEvents]); + }, [data.order, data.entries, selectedEvents]); if (!event) { return ; diff --git a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx index 1062a2d8d..66eb7a596 100644 --- a/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx +++ b/apps/client/src/features/rundown/quick-add-block/QuickAddBlock.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useRef } from 'react'; import { IoAdd } from 'react-icons/io5'; import { Button } from '@chakra-ui/react'; -import { SupportedEvent } from 'ontime-types'; +import { MaybeString, SupportedEvent } from 'ontime-types'; import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEmitLog } from '../../../common/stores/logger'; @@ -9,7 +9,7 @@ import { useEmitLog } from '../../../common/stores/logger'; import style from './QuickAddBlock.module.scss'; interface QuickAddBlockProps { - previousEventId?: string; + previousEventId: MaybeString; } export default memo(QuickAddBlock); diff --git a/apps/client/src/features/rundown/useEventSelection.ts b/apps/client/src/features/rundown/useEventSelection.ts index d286abb71..c464756ce 100644 --- a/apps/client/src/features/rundown/useEventSelection.ts +++ b/apps/client/src/features/rundown/useEventSelection.ts @@ -1,5 +1,5 @@ import { MouseEvent } from 'react'; -import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types'; +import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, Rundown } from 'ontime-types'; import { create } from 'zustand'; import { RUNDOWN } from '../../common/api/constants'; @@ -33,7 +33,7 @@ export const useEventSelection = create()((set, get) => ({ // on ctrl + click, we toggle the selection of that event if (selectMode === 'ctrl') { - const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); + const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); if (!rundownData) return; // if it doesnt exist, simply add to the list and set an anchor @@ -50,7 +50,7 @@ export const useEventSelection = create()((set, get) => ({ selectedEvents.delete(id); const nextIndex = rundownData.order.findIndex( - (eventId, i) => i > index && isOntimeEvent(rundownData.rundown[eventId]) && selectedEvents.has(eventId), + (eventId, i) => i > index && isOntimeEvent(rundownData.entries[eventId]) && selectedEvents.has(eventId), ); // if we didnt find anything after, set the anchor to the last event @@ -62,13 +62,13 @@ export const useEventSelection = create()((set, get) => ({ // on shift + click, we select a range of events up to the clicked event if (selectMode === 'shift') { - const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); + const rundownData = ontimeQueryClient.getQueryData(RUNDOWN); if (!rundownData) return; // get list of rundown with only ontime events const events: OntimeEvent[] = []; rundownData.order.forEach((eventId) => { - const event = rundownData.rundown[eventId]; + const event = rundownData.entries[eventId]; if (isOntimeEvent(event)) { events.push(event); } diff --git a/apps/client/src/features/viewers/countdown/Countdown.tsx b/apps/client/src/features/viewers/countdown/Countdown.tsx index e74bce499..3cf69c2e8 100644 --- a/apps/client/src/features/viewers/countdown/Countdown.tsx +++ b/apps/client/src/features/viewers/countdown/Countdown.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { + OntimeEntry, OntimeEvent, - OntimeRundownEntry, Playback, ProjectData, Runtime, @@ -72,7 +72,7 @@ export default function Countdown(props: CountdownProps) { } if (followThis !== null) { setFollow(followThis); - const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id); + const idx: number = backstageEvents.findIndex((event: OntimeEntry) => event.id === followThis?.id); const delayToEvent = backstageEvents[idx]?.delay ?? 0; setDelay(delayToEvent); } diff --git a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx index a23eddf4b..8cfcf78fb 100644 --- a/apps/client/src/features/viewers/countdown/CountdownSelect.tsx +++ b/apps/client/src/features/viewers/countdown/CountdownSelect.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom'; -import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types'; +import { OntimeEntry, OntimeEvent, SupportedEvent } from 'ontime-types'; import Empty from '../../../common/components/state/Empty'; import { formatTime } from '../../../common/utils/time'; @@ -10,7 +10,7 @@ import { sanitiseTitle } from './countdown.helpers'; import './Countdown.scss'; interface CountdownSelectProps { - events: OntimeRundownEntry[]; + events: OntimeEntry[]; } const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' }; @@ -19,9 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) { const { events } = props; const { getLocalizedString } = useTranslation(); - const filteredEvents = events.filter( - (event: OntimeRundownEntry) => event.type === SupportedEvent.Event, - ) as OntimeEvent[]; + const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEvent.Event) as OntimeEvent[]; return (
diff --git a/apps/client/src/features/viewers/studio/StudioClock.tsx b/apps/client/src/features/viewers/studio/StudioClock.tsx index 0c6a85220..c036d6e04 100644 --- a/apps/client/src/features/viewers/studio/StudioClock.tsx +++ b/apps/client/src/features/viewers/studio/StudioClock.tsx @@ -1,5 +1,5 @@ import { useSearchParams } from 'react-router-dom'; -import type { MaybeString, OntimeEvent, OntimeRundown, ProjectData, Settings } from 'ontime-types'; +import type { MaybeString, OntimeEntry, OntimeEvent, ProjectData, Settings } from 'ontime-types'; import { Playback } from 'ontime-types'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils'; @@ -17,7 +17,7 @@ import StudioClockSchedule from './StudioClockSchedule'; import './StudioClock.scss'; interface StudioClockProps { - backstageEvents: OntimeRundown; + backstageEvents: OntimeEntry[]; eventNext: OntimeEvent | null; general: ProjectData; isMirrored: boolean; diff --git a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx b/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx index c97f7057b..b1ba3a54c 100644 --- a/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx +++ b/apps/client/src/features/viewers/studio/StudioClockSchedule.tsx @@ -1,4 +1,4 @@ -import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown } from 'ontime-types'; +import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent } from 'ontime-types'; import { formatTime } from '../../../common/utils/time'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; @@ -8,7 +8,7 @@ import { trimRundown } from './studioClock.utils'; import './StudioClock.scss'; interface StudioClockScheduleProps { - rundown: OntimeRundown; + rundown: OntimeEntry[]; selectedId: MaybeString; nextId: MaybeString; onAir: boolean; diff --git a/apps/client/src/views/common/schedule/ScheduleContext.tsx b/apps/client/src/views/common/schedule/ScheduleContext.tsx index 429ceb83a..5d78ac23b 100644 --- a/apps/client/src/views/common/schedule/ScheduleContext.tsx +++ b/apps/client/src/views/common/schedule/ScheduleContext.tsx @@ -8,7 +8,7 @@ import { useRef, useState, } from 'react'; -import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types'; import { usePartialRundown } from '../../../common/hooks-query/useRundown'; @@ -36,7 +36,7 @@ export const ScheduleProvider = ({ isBackstage = false, }: PropsWithChildren) => { const { cycleInterval, stopCycle } = useScheduleOptions(); - const { data: events } = usePartialRundown((event: OntimeRundownEntry) => { + const { data: events } = usePartialRundown((event: OntimeEntry) => { if (isBackstage) { return isOntimeEvent(event); } diff --git a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx index 64bf10a67..4960f1a60 100644 --- a/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-dnd/CuesheetDnd.tsx @@ -9,12 +9,12 @@ import { useSensors, } from '@dnd-kit/core'; import { ColumnDef } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry } from 'ontime-types'; import useColumnManager from '../cuesheet-table/useColumnManager'; interface CuesheetDndProps { - columns: ColumnDef[]; + columns: ColumnDef[]; } export default function CuesheetDnd(props: PropsWithChildren) { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index d56875be7..a3d3c59d6 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -1,7 +1,7 @@ import { useCallback, useRef } from 'react'; import { useTableNav } from '@table-nav/react'; import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundown, OntimeRundownEntry, TimeField } from 'ontime-types'; +import { isOntimeEvent, MaybeString, OntimeEntry, OntimeEvent, TimeField } from 'ontime-types'; import { useEventAction } from '../../../common/hooks/useEventAction'; import useFollowComponent from '../../../common/hooks/useFollowComponent'; @@ -16,8 +16,8 @@ import useColumnManager from './useColumnManager'; import style from './CuesheetTable.module.scss'; interface CuesheetTableProps { - data: OntimeRundown; - columns: ColumnDef[]; + data: OntimeEntry[]; + columns: ColumnDef[]; showModal: (eventId: MaybeString) => void; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx index e5fe04f96..e4d722b1b 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetBody.tsx @@ -1,7 +1,7 @@ import { MutableRefObject } from 'react'; import { RowModel, Table } from '@tanstack/react-table'; import Color from 'color'; -import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeEntry } from 'ontime-types'; import { useSelectedEventId } from '../../../../common/hooks/useSocket'; import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate'; @@ -13,9 +13,9 @@ import DelayRow from './DelayRow'; import EventRow from './EventRow'; interface CuesheetBodyProps { - rowModel: RowModel; + rowModel: RowModel; selectedRef: MutableRefObject; - table: Table; + table: Table; } export default function CuesheetBody(props: CuesheetBodyProps) { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx index 794705ca9..15511bafc 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/CuesheetHeader.tsx @@ -1,6 +1,6 @@ import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { flexRender, HeaderGroup } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry } from 'ontime-types'; import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { useCuesheetOptions } from '../../cuesheet.options'; @@ -10,7 +10,7 @@ import { SortableCell } from './SortableCell'; import style from '../CuesheetTable.module.scss'; interface CuesheetHeaderProps { - headerGroups: HeaderGroup[]; + headerGroups: HeaderGroup[]; } export default function CuesheetHeader(props: CuesheetHeaderProps) { diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index a16be1e6e..5e67bba42 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react import { IoEllipsisHorizontal } from 'react-icons/io5'; import { flexRender, Table } from '@tanstack/react-table'; import Color from 'color'; -import { OntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry, OntimeEvent } from 'ontime-types'; import IconButton from '../../../../common/components/buttons/IconButton'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; @@ -21,7 +21,7 @@ interface EventRowProps { skip?: boolean; colour?: string; rowBgColour?: string; - table: Table; + table: Table; /** hack to force re-rendering of the row when the column sizes change */ columnHash: string; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx index 05bd08954..9ccf3f20f 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/SortableCell.tsx @@ -2,12 +2,12 @@ import { CSSProperties, ReactNode } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Header } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry } from 'ontime-types'; import styles from '../CuesheetTable.module.scss'; interface SortableCellProps { - header: Header; + header: Header; style: CSSProperties; children: ReactNode; } diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx index d3e75b9b6..9fb86773d 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/cuesheetCols.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; import { CellContext, ColumnDef } from '@tanstack/react-table'; -import { CustomFields, isOntimeEvent, OntimeEvent, OntimeRundownEntry, TimeStrategy } from 'ontime-types'; +import { CustomFields, isOntimeEvent, OntimeEntry, OntimeEvent, TimeStrategy } from 'ontime-types'; import { millisToString, removeSeconds } from 'ontime-utils'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; @@ -11,7 +11,7 @@ import MultiLineCell from './MultiLineCell'; import SingleLineCell from './SingleLineCell'; import TimeInput from './TimeInput'; -function MakeStart({ getValue, row, table }: CellContext) { +function MakeStart({ getValue, row, table }: CellContext) { if (!table.options.meta) { return null; } @@ -39,7 +39,7 @@ function MakeStart({ getValue, row, table }: CellContext) { +function MakeEnd({ getValue, row, table }: CellContext) { if (!table.options.meta) { return null; } @@ -67,7 +67,7 @@ function MakeEnd({ getValue, row, table }: CellContext) { +function MakeDuration({ getValue, row, table }: CellContext) { if (!table.options.meta) { return null; } @@ -87,7 +87,7 @@ function MakeDuration({ getValue, row, table }: CellContext) { +function MakeMultiLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); @@ -101,12 +101,12 @@ function MakeMultiLineField({ row, column, table }: CellContext; + return ; } -function LazyImage({ row, column, table }: CellContext) { +function LazyImage({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); @@ -124,7 +124,7 @@ function LazyImage({ row, column, table }: CellContext; } -function MakeSingleLineField({ row, column, table }: CellContext) { +function MakeSingleLineField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, false); @@ -138,12 +138,12 @@ function MakeSingleLineField({ row, column, table }: CellContext; + return ; } -function MakeCustomField({ row, column, table }: CellContext) { +function MakeCustomField({ row, column, table }: CellContext) { const update = useCallback( (newValue: string) => { table.options.meta?.handleUpdate(row.index, column.id, newValue, true); @@ -161,7 +161,7 @@ function MakeCustomField({ row, column, table }: CellContext; } -export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { +export function makeCuesheetColumns(customFields: CustomFields): ColumnDef[] { const dynamicCustomFields = Object.keys(customFields).map((key) => ({ accessorKey: key, id: key, diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx index 3ab8b6d18..63d4a2d96 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-settings/CuesheetTableSettings.tsx @@ -1,7 +1,7 @@ import { memo, ReactNode } from 'react'; import { Button, Checkbox } from '@chakra-ui/react'; import { Column } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry } from 'ontime-types'; import * as Editor from '../../../../features/editors/editor-utils/EditorUtils'; @@ -14,7 +14,7 @@ const buttonProps = { }; interface CuesheetTableSettingsProps { - columns: Column[]; + columns: Column[]; handleResetResizing: () => void; handleResetReordering: () => void; handleClearToggles: () => void; diff --git a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx index 7e8e33aaa..28d41e427 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/useColumnManager.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect } from 'react'; import { useLocalStorage } from '@mantine/hooks'; import { ColumnDef } from '@tanstack/react-table'; -import { OntimeRundownEntry } from 'ontime-types'; +import { OntimeEntry } from 'ontime-types'; -export default function useColumnManager(columns: ColumnDef[]) { +export default function useColumnManager(columns: ColumnDef[]) { const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const [columnOrder, saveColumnOrder] = useLocalStorage({ key: 'table-order', diff --git a/apps/client/src/views/cuesheet/cuesheet.utils.ts b/apps/client/src/views/cuesheet/cuesheet.utils.ts index 75cce4af8..b3b255a80 100644 --- a/apps/client/src/views/cuesheet/cuesheet.utils.ts +++ b/apps/client/src/views/cuesheet/cuesheet.utils.ts @@ -3,8 +3,8 @@ import { isOntimeDelay, isOntimeEvent, MaybeNumber, + OntimeEntry, OntimeEntryCommonKeys, - OntimeRundown, ProjectData, } from 'ontime-types'; import { millisToString } from 'ontime-utils'; @@ -32,12 +32,8 @@ export const parseField = (field: CsvHeaderKey, data: unknown): string => { /** * @description Creates an array of arrays usable by xlsx for export - * @param {ProjectData} headerData - * @param {OntimeRundown} rundown - * @param {CustomFields} customFields - * @return {(string[])[]} */ -export const makeTable = (headerData: ProjectData, rundown: OntimeRundown, customFields: CustomFields): string[][] => { +export const makeTable = (headerData: ProjectData, rundown: OntimeEntry[], customFields: CustomFields): string[][] => { // create metadata header row const data = [['Ontime · Rundown export']]; if (headerData.title) data.push([`Project title: ${headerData.title}`]); diff --git a/apps/client/src/views/timeline/Timeline.tsx b/apps/client/src/views/timeline/Timeline.tsx index c191bcb4e..782ffbdcd 100644 --- a/apps/client/src/views/timeline/Timeline.tsx +++ b/apps/client/src/views/timeline/Timeline.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; import { useViewportSize } from '@mantine/hooks'; -import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types'; +import { isOntimeEvent, isPlayableEvent, OntimeEntry } from 'ontime-types'; import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils'; import TimelineMarkers from './timeline-markers/TimelineMarkers'; @@ -11,7 +11,7 @@ import style from './Timeline.module.scss'; interface TimelineProps { firstStart: number; - rundown: OntimeRundown; + rundown: OntimeEntry[]; selectedEventId: string | null; totalDuration: number; } diff --git a/apps/client/src/views/timeline/timeline.utils.ts b/apps/client/src/views/timeline/timeline.utils.ts index fd43ffd2e..8c1068123 100644 --- a/apps/client/src/views/timeline/timeline.utils.ts +++ b/apps/client/src/views/timeline/timeline.utils.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEvent, OntimeRundown, PlayableEvent } from 'ontime-types'; +import { isOntimeEvent, isPlayableEvent, MaybeString, OntimeEntry, OntimeEvent, PlayableEvent } from 'ontime-types'; import { dayInMs, getEventWithId, @@ -87,7 +87,7 @@ interface ScopedRundownData { totalDuration: number; } -export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData { +export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData { const [searchParams] = useSearchParams(); const data = useMemo(() => { @@ -102,7 +102,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS let selectedIndex = selectedEventId ? Infinity : -1; let firstStart = null; let totalDuration = 0; - let lastEntry: PlayableEvent | undefined; + let lastEntry: PlayableEvent | null = null; for (let i = 0; i < rundown.length; i++) { const currentEntry = rundown[i]; @@ -164,7 +164,7 @@ type UpcomingEvents = { /** * Returns upcoming events from current: now, next and followedBy */ -export function getUpcomingEvents(events: OntimeRundown, selectedId: MaybeString): UpcomingEvents { +export function getUpcomingEvents(events: PlayableEvent[], selectedId: MaybeString): UpcomingEvents { if (events.length === 0) { return { now: null, next: null, followedBy: null }; } diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts index 344984fc2..a2ddadf41 100644 --- a/apps/server/src/api-data/automation/automation.dao.ts +++ b/apps/server/src/api-data/automation/automation.dao.ts @@ -169,7 +169,12 @@ async function saveChanges(patch: Partial) { const automation = getDataProvider().getAutomation(); // remove undefined keys from object, we probably want a better solution - Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {})); + Object.keys(patch).forEach((key) => { + const typedKey = key as keyof AutomationSettings; + if (patch[typedKey] === undefined) { + delete patch[typedKey]; + } + }); await getDataProvider().setAutomation({ ...automation, ...patch }); } diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 2009056e1..8601000ce 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -18,9 +18,9 @@ import * as projectService from '../../services/project-service/ProjectService.j export async function patchPartialProjectFile(req: Request, res: Response) { try { - const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body; + const { rundowns, project, settings, viewSettings, urlPresets, customFields, automation } = req.body; const patchDb: DatabaseModel = { - rundown, + rundowns, project, settings, viewSettings, diff --git a/apps/server/src/api-data/db/db.middleware.ts b/apps/server/src/api-data/db/db.middleware.ts index 26afef000..4c9db3fbc 100644 --- a/apps/server/src/api-data/db/db.middleware.ts +++ b/apps/server/src/api-data/db/db.middleware.ts @@ -18,7 +18,7 @@ const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilte } else { cb(null, false); } -} +}; // Build multer uploader for a single file export const uploadProjectFile = multer({ diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts index 49aa8259b..2f4706eec 100644 --- a/apps/server/src/api-data/db/db.validation.ts +++ b/apps/server/src/api-data/db/db.validation.ts @@ -59,7 +59,7 @@ export const validatePatchProject = [ next(); }, - body('rundown').isArray().optional({ nullable: false }), + body('rundowns').isObject().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }), body('settings').isObject().optional({ nullable: false }), body('viewSettings').isObject().optional({ nullable: false }), diff --git a/apps/server/src/api-data/excel/excel.controller.ts b/apps/server/src/api-data/excel/excel.controller.ts index e47bcf1b9..4d1436fe4 100644 --- a/apps/server/src/api-data/excel/excel.controller.ts +++ b/apps/server/src/api-data/excel/excel.controller.ts @@ -5,6 +5,7 @@ import type { Request, Response } from 'express'; import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js'; +import { CustomFields, Rundown } from 'ontime-types'; export async function postExcel(req: Request, res: Response) { try { @@ -29,7 +30,10 @@ export async function getWorksheets(req: Request, res: Response) { * parses an Excel spreadsheet * @returns parsed result */ -export async function previewExcel(req: Request, res: Response) { +export async function previewExcel( + req: Request, + res: Response<{ rundown: Rundown; customFields: CustomFields } | { message: string }>, +) { try { const { options } = req.body; const data = generateRundownPreview(options); diff --git a/apps/server/src/api-data/excel/excel.service.ts b/apps/server/src/api-data/excel/excel.service.ts index 70d32e280..383b5c129 100644 --- a/apps/server/src/api-data/excel/excel.service.ts +++ b/apps/server/src/api-data/excel/excel.service.ts @@ -3,8 +3,8 @@ * Google Sheets */ -import { CustomFields, OntimeRundown } from 'ontime-types'; -import type { ImportMap } from 'ontime-utils'; +import { CustomFields, Rundown } from 'ontime-types'; +import { type ImportMap } from 'ontime-utils'; import { extname } from 'path'; import { existsSync } from 'fs'; @@ -12,7 +12,7 @@ import xlsx from 'xlsx'; import type { WorkBook } from 'xlsx'; import { parseExcel } from '../../utils/parser.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; +import { parseCustomFields, parseRundown } from '../../utils/parserFunctions.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { getCustomFields } from '../../services/rundown-service/rundownCache.js'; @@ -34,7 +34,7 @@ export function listWorksheets(): string[] { return excelData.SheetNames; } -export function generateRundownPreview(options: ImportMap): { rundown: OntimeRundown; customFields: CustomFields } { +export function generateRundownPreview(options: ImportMap): { rundown: Rundown; customFields: CustomFields } { const data = excelData.Sheets[options.worksheet]; if (!data) { @@ -43,15 +43,17 @@ export function generateRundownPreview(options: ImportMap): { rundown: OntimeRun const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false }); - const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options); + const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options); + const parsedCustomFields = parseCustomFields(dataFromExcel); + // we run the parsed data through an extra step to ensure the objects shape - const { rundown, customFields } = parseRundown(dataFromExcel); - if (rundown.length === 0) { + const Rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields); + if (Rundown.order.length === 0) { throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); } // clear the data excelData = xlsx.utils.book_new(); - return { rundown, customFields }; + return { rundown: Rundown, customFields: parsedCustomFields }; } diff --git a/apps/server/src/api-data/rundown/rundown.controller.ts b/apps/server/src/api-data/rundown/rundown.controller.ts index f40ab53b6..dde0501dd 100644 --- a/apps/server/src/api-data/rundown/rundown.controller.ts +++ b/apps/server/src/api-data/rundown/rundown.controller.ts @@ -1,4 +1,4 @@ -import { ErrorResponse, MessageResponse, OntimeRundown, OntimeRundownEntry, RundownCached } from 'ontime-types'; +import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import type { Request, Response } from 'express'; @@ -14,19 +14,19 @@ import { reorderEvent, swapEvents, } from '../../services/rundown-service/RundownService.js'; -import { getEventWithId, getNormalisedRundown, getRundown } from '../../services/rundown-service/rundownUtils.js'; +import { getEventWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js'; -export async function rundownGetAll(_req: Request, res: Response) { - const rundown = getRundown(); - res.json(rundown); +export async function rundownGetAll(_req: Request, res: Response) { + const rundown = getCurrentRundown(); + res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]); } -export async function rundownGetNormalised(_req: Request, res: Response) { - const cachedRundown = getNormalisedRundown(); +export async function rundownGetCurrent(_req: Request, res: Response) { + const cachedRundown = getCurrentRundown(); res.json(cachedRundown); } -export async function rundownGetById(req: Request, res: Response) { +export async function rundownGetById(req: Request, res: Response) { const { eventId } = req.params; try { @@ -43,7 +43,7 @@ export async function rundownGetById(req: Request, res: Response) { +export async function rundownPost(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } @@ -57,7 +57,7 @@ export async function rundownPost(req: Request, res: Response) { +export async function rundownPut(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } @@ -86,7 +86,7 @@ export async function rundownBatchPut(req: Request, res: Response) { +export async function rundownReorder(req: Request, res: Response) { if (failEmptyObjects(req.body, res)) { return; } diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 38b6cd15a..68f55e1d3 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -7,7 +7,7 @@ import { rundownDelete, rundownGetAll, rundownGetById, - rundownGetNormalised, + rundownGetCurrent, rundownPost, rundownPut, rundownReorder, @@ -25,8 +25,8 @@ import { export const router = express.Router(); -router.get('/', rundownGetAll); // not used in Ontime frontend -router.get('/normalised', rundownGetNormalised); +router.get('/', rundownGetAll); +router.get('/current', rundownGetCurrent); router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend router.post('/', rundownPostValidator, rundownPost); diff --git a/apps/server/src/api-data/sheets/sheets.controller.ts b/apps/server/src/api-data/sheets/sheets.controller.ts index 026e5384a..b82935b51 100644 --- a/apps/server/src/api-data/sheets/sheets.controller.ts +++ b/apps/server/src/api-data/sheets/sheets.controller.ts @@ -6,7 +6,7 @@ import { Request, Response } from 'express'; import { readFileSync } from 'fs'; -import type { AuthenticationStatus, CustomFields, ErrorResponse, OntimeRundown } from 'ontime-types'; +import type { AuthenticationStatus, CustomFields, ErrorResponse, Rundown } from 'ontime-types'; import { deleteFile } from '../../utils/parserUtils.js'; import { @@ -87,7 +87,7 @@ export async function readFromSheet( req: Request, res: Response< | { - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; } | ErrorResponse diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 73e2d46b1..8cb74006c 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -1,12 +1,13 @@ import { ProjectData, - OntimeRundown, ViewSettings, DatabaseModel, Settings, CustomFields, URLPreset, AutomationSettings, + Rundown, + ProjectRundowns, } from 'ontime-types'; import type { Low } from 'lowdb'; @@ -45,6 +46,7 @@ export function getDataProvider() { setCustomFields, getCustomFields, setRundown, + mergeRundown, getSettings, setSettings, getUrlPresets, @@ -78,14 +80,28 @@ async function setCustomFields(newData: CustomFields): ReadonlyPromise { + db.data.customFields = { ...db.data.customFields, ...newCustomFields }; + + Object.entries(newRundowns).forEach(([id, rundown]) => { + // Note that entries with the same key will be overridden + db.data.rundowns[id] = rundown; + }); + await persist(); + return { rundowns: db.data.rundowns, customFields: db.data.customFields }; +} + function getCustomFields(): Readonly { return db.data.customFields; } -async function setRundown(newData: OntimeRundown): ReadonlyPromise { - db.data.rundown = newData; +async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise { + db.data.rundowns[rundownKey] = newData; await persist(); - return db.data.rundown; + return db.data.rundowns[rundownKey]; } function getSettings(): Readonly { @@ -128,8 +144,9 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise { - return db.data.rundown; +function getRundown(): Readonly { + const firstRundown = Object.keys(db.data.rundowns)[0]; + return db.data.rundowns[firstRundown]; } async function mergeIntoData(newData: Partial): ReadonlyPromise { @@ -140,7 +157,7 @@ async function mergeIntoData(newData: Partial): ReadonlyPromise { @@ -51,23 +55,43 @@ describe('safeMerge', () => { } as DatabaseModel; it('returns existing data if new data is not provided', () => { - const mergedData = safeMerge(existing, {}); - expect(mergedData).toEqual(existing); + const mergedData = safeMerge(demoDb, {}); + expect(mergedData).toEqual(demoDb); }); - it('merges the rundown key', () => { - const newData = { - rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown, - }; - const mergedData = safeMerge(existing, newData); - expect(mergedData.rundown).toEqual(newData.rundown); + it('overrides a rundown with the same key', () => { + const newData = makeRundown({ + id: 'demo', + entries: { + '1': makeOntimeEvent({ id: '1', title: 'new title' }), + '2': makeOntimeEvent({ id: '1', title: 'new title' }), + }, + order: ['1', '2'], + }); + const mergedData = safeMerge(demoDb, { rundowns: { demo: newData } }); + expect(mergedData.rundowns.demo).toStrictEqual(newData); + }); + + it('merges a rundown with a new key', () => { + const newData = makeRundown({ + id: 'rundown', + entries: { + '1': makeOntimeEvent({ id: '1', title: 'new title' }), + '2': makeOntimeEvent({ id: '1', title: 'new title' }), + }, + order: ['1', '2'], + }); + const mergedData = safeMerge(demoDb, { rundowns: { rundown: newData } }); + expect(mergedData.rundowns.demo).toStrictEqual(demoDb.rundowns.demo); + expect(mergedData.rundowns.rundown).toStrictEqual(newData); }); it('merges the project key', () => { - const newData = { + const mergedData = safeMerge(demoDb, { project: { title: 'new title', publicInfo: 'new public info', + backstageInfo: 'new backstage info', custom: [ { title: 'new custom title', @@ -75,16 +99,15 @@ describe('safeMerge', () => { }, ], }, - }; - // @ts-expect-error -- just testing - const mergedData = safeMerge(existing, newData); - expect(mergedData.project).toEqual({ + } as Partial); + + expect(mergedData.project).toStrictEqual({ title: 'new title', - description: 'existing description', - publicUrl: 'existing public URL', + description: 'Turin 2022', + publicUrl: 'www.getontime.no', publicInfo: 'new public info', - backstageUrl: 'existing backstageUrl', - backstageInfo: 'existing backstageInfo', + backstageUrl: 'www.github.com/cpvalente/ontime', + backstageInfo: 'new backstage info', projectLogo: null, custom: [ { @@ -96,16 +119,16 @@ describe('safeMerge', () => { }); it('merges the settings key', () => { - const newData = { + const mergedData = safeMerge(demoDb, { settings: { serverPort: 3000, language: 'pt', + version: 'new', } as Settings, - }; - const mergedData = safeMerge(existing, newData); - expect(mergedData.settings).toEqual({ + }); + expect(mergedData.settings).toStrictEqual({ app: 'ontime', - version: '2.0.0', + version: 'new', serverPort: 3000, operatorKey: null, editorKey: null, @@ -158,9 +181,9 @@ describe('safeMerge', () => { ] as URLPreset[], }; - const mergedData = safeMerge(existingData, newData); + const mergedData = safeMerge(demoDb, newData); - expect(mergedData.urlPresets).toEqual(newData.urlPresets); + expect(mergedData.urlPresets).toStrictEqual(newData.urlPresets); }); it('merges customFields into existing object', () => { diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 21e454ef4..6676c2ec6 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -1,8 +1,18 @@ -import { DatabaseModel } from 'ontime-types'; +import { DatabaseModel, Rundown } from 'ontime-types'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; +export const defaultRundown: Rundown = { + id: 'default', + title: 'Default', + order: [], + entries: {}, + revision: 0, +}; + export const dbModel: DatabaseModel = { - rundown: [], + rundowns: { + default: { ...defaultRundown }, + }, project: { title: '', description: '', diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 7bc692a9a..60a3d7d49 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -1,410 +1,475 @@ import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types'; export const demoDb: DatabaseModel = { - rundown: [ - { - type: SupportedEvent.Event, - id: '32d31', - cue: 'SF1.01', - title: 'Albania', - note: 'SF1.01', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 36000000, - timeEnd: 37200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Sekret', - artist: 'Ronela Hajati', + rundowns: { + demo: { + id: 'demo', + title: 'Eurovision Demo', + order: [ + '32d31', + '21cd2', + '0b371', + '3cd28', + 'e457f', + '01e85', + '1c420', + 'b7737', + 'd3a80', + '8276c', + '2340b', + 'cb90b', + '503c4', + '5e965', + 'bab4a', + 'd3eb1', + ], + entries: { + '32d31': { + type: SupportedEvent.Event, + id: '32d31', + cue: 'SF1.01', + title: 'Albania', + note: 'SF1.01', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 36000000, + timeEnd: 37200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Sekret', + artist: 'Ronela Hajati', + }, + }, + '21cd2': { + type: SupportedEvent.Event, + id: '21cd2', + cue: 'SF1.02', + title: 'Latvia', + note: 'SF1.02', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 37500000, + timeEnd: 38700000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Eat Your Salad', + artist: 'Citi Zeni', + }, + }, + '0b371': { + type: SupportedEvent.Event, + id: '0b371', + cue: 'SF1.03', + title: 'Lithuania', + note: 'SF1.03', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 39000000, + timeEnd: 40200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Sentimentai', + artist: 'Monika Liu', + }, + }, + '3cd28': { + type: SupportedEvent.Event, + id: '3cd28', + cue: 'SF1.04', + title: 'Switzerland', + note: 'SF1.04', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 40500000, + timeEnd: 41700000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Boys Do Cry', + artist: 'Marius Bear', + }, + }, + e457f: { + type: SupportedEvent.Event, + id: 'e457f', + cue: 'SF1.05', + title: 'Slovenia', + note: 'SF1.05', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 42000000, + timeEnd: 43200000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Disko', + artist: 'LPS', + }, + }, + /// <----- BLOCK + '01e85': { + // TODO: this should be a marker type + type: SupportedEvent.Block, + id: '01e85', + title: 'Lunch break', + note: '', + colour: '', + events: [], + skip: false, + custom: {}, + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: 0, + }, + '1c420': { + type: SupportedEvent.Event, + id: '1c420', + cue: 'SF1.06', + title: 'Ukraine', + note: 'SF1.06', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 47100000, + timeEnd: 48300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Stefania', + artist: 'Kalush Orchestra', + }, + }, + b7737: { + type: SupportedEvent.Event, + id: 'b7737', + cue: 'SF1.07', + title: 'Bulgaria', + note: 'SF1.07', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 48600000, + timeEnd: 49800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Intention', + artist: 'Intelligent Music Project', + }, + }, + d3a80: { + type: SupportedEvent.Event, + id: 'd3a80', + cue: 'SF1.08', + title: 'Netherlands', + note: 'SF1.08', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 50100000, + timeEnd: 51300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'De Diepte', + artist: 'S10', + }, + }, + '8276c': { + type: SupportedEvent.Event, + id: '8276c', + cue: 'SF1.09', + title: 'Moldova', + note: 'SF1.09', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 51600000, + timeEnd: 52800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Trenuletul', + artist: 'Zdob si Zdub', + }, + }, + '2340b': { + type: SupportedEvent.Event, + id: '2340b', + cue: 'SF1.10', + title: 'Portugal', + note: 'SF1.10', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 53100000, + timeEnd: 54300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Saudade Saudade', + artist: 'Maro', + }, + }, + /// <----- BLOCK + cb90b: { + // TODO: This should be a marker type + type: SupportedEvent.Block, + id: 'cb90b', + title: 'Afternoon break', + note: '', + colour: '', + events: [], + skip: false, + custom: {}, + revision: 0, + startTime: null, + endTime: null, + duration: 0, + isFirstLinked: false, + numEvents: 0, + }, + '503c4': { + type: SupportedEvent.Event, + id: '503c4', + cue: 'SF1.11', + title: 'Croatia', + note: 'SF1.11', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 56100000, + timeEnd: 57300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Guilty Pleasure', + artist: 'Mia Dimsic', + }, + }, + '5e965': { + type: SupportedEvent.Event, + id: '5e965', + cue: 'SF1.12', + title: 'Denmark', + note: 'SF1.12', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 57600000, + timeEnd: 58800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'The Show', + artist: 'Reddi', + }, + }, + bab4a: { + type: SupportedEvent.Event, + id: 'bab4a', + cue: 'SF1.13', + title: 'Austria', + note: 'SF1.13', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 59100000, + timeEnd: 60300000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Halo', + artist: 'LUM!X & Pia Maria', + }, + }, + d3eb1: { + type: SupportedEvent.Event, + id: 'd3eb1', + cue: 'SF1.14', + title: 'Greece', + note: 'SF1.14', + endAction: EndAction.None, + timerType: TimerType.CountDown, + countToEnd: false, + linkStart: null, + timeStrategy: TimeStrategy.LockEnd, + timeStart: 60600000, + timeEnd: 61800000, + duration: 1200000, + isPublic: true, + skip: false, + colour: '', + currentBlock: null, + revision: 0, + delay: 0, + dayOffset: 0, + gap: 0, + timeWarning: 500000, + timeDanger: 100000, + custom: { + song: 'Die Together', + artist: 'Amanda Tenfjord', + }, + }, }, - }, - { - type: SupportedEvent.Event, - id: '21cd2', - cue: 'SF1.02', - title: 'Latvia', - note: 'SF1.02', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 37500000, - timeEnd: 38700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Eat Your Salad', - artist: 'Citi Zeni', - }, }, - { - type: SupportedEvent.Event, - id: '0b371', - cue: 'SF1.03', - title: 'Lithuania', - note: 'SF1.03', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 39000000, - timeEnd: 40200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Sentimentai', - artist: 'Monika Liu', - }, - }, - { - type: SupportedEvent.Event, - id: '3cd28', - cue: 'SF1.04', - title: 'Switzerland', - note: 'SF1.04', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 40500000, - timeEnd: 41700000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Boys Do Cry', - artist: 'Marius Bear', - }, - }, - { - type: SupportedEvent.Event, - id: 'e457f', - cue: 'SF1.05', - title: 'Slovenia', - note: 'SF1.05', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 42000000, - timeEnd: 43200000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Disko', - artist: 'LPS', - }, - }, - { - type: SupportedEvent.Block, - id: '01e85', - title: 'Lunch break', - }, - { - type: SupportedEvent.Event, - id: '1c420', - cue: 'SF1.06', - title: 'Ukraine', - note: 'SF1.06', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 47100000, - timeEnd: 48300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Stefania', - artist: 'Kalush Orchestra', - }, - }, - { - type: SupportedEvent.Event, - id: 'b7737', - cue: 'SF1.07', - title: 'Bulgaria', - note: 'SF1.07', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 48600000, - timeEnd: 49800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Intention', - artist: 'Intelligent Music Project', - }, - }, - { - type: SupportedEvent.Event, - id: 'd3a80', - cue: 'SF1.08', - title: 'Netherlands', - note: 'SF1.08', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 50100000, - timeEnd: 51300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'De Diepte', - artist: 'S10', - }, - }, - { - type: SupportedEvent.Event, - id: '8276c', - cue: 'SF1.09', - title: 'Moldova', - note: 'SF1.09', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 51600000, - timeEnd: 52800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Trenuletul', - artist: 'Zdob si Zdub', - }, - }, - { - type: SupportedEvent.Event, - id: '2340b', - cue: 'SF1.10', - title: 'Portugal', - note: 'SF1.10', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 53100000, - timeEnd: 54300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Saudade Saudade', - artist: 'Maro', - }, - }, - { - type: SupportedEvent.Block, - id: 'cb90b', - title: 'Afternoon break', - }, - { - type: SupportedEvent.Event, - id: '503c4', - cue: 'SF1.11', - title: 'Croatia', - note: 'SF1.11', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 56100000, - timeEnd: 57300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Guilty Pleasure', - artist: 'Mia Dimsic', - }, - }, - { - type: SupportedEvent.Event, - id: '5e965', - cue: 'SF1.12', - title: 'Denmark', - note: 'SF1.12', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 57600000, - timeEnd: 58800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'The Show', - artist: 'Reddi', - }, - }, - { - type: SupportedEvent.Event, - id: 'bab4a', - cue: 'SF1.13', - title: 'Austria', - note: 'SF1.13', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 59100000, - timeEnd: 60300000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Halo', - artist: 'LUM!X & Pia Maria', - }, - }, - { - type: SupportedEvent.Event, - id: 'd3eb1', - cue: 'SF1.14', - title: 'Greece', - note: 'SF1.14', - endAction: EndAction.None, - timerType: TimerType.CountDown, - countToEnd: false, - linkStart: null, - timeStrategy: TimeStrategy.LockEnd, - timeStart: 60600000, - timeEnd: 61800000, - duration: 1200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, - timeWarning: 500000, - timeDanger: 100000, - custom: { - song: 'Die Together', - artist: 'Amanda Tenfjord', - }, - }, - ], + }, project: { title: 'Eurovision Song Contest', description: 'Turin 2022', @@ -417,7 +482,7 @@ export const demoDb: DatabaseModel = { }, settings: { app: 'ontime', - version: '3.3.2', + version: '-', serverPort: 4001, editorKey: null, operatorKey: null, diff --git a/apps/server/src/models/eventsDefinition.ts b/apps/server/src/models/eventsDefinition.ts index d8b0846c5..725aedac2 100644 --- a/apps/server/src/models/eventsDefinition.ts +++ b/apps/server/src/models/eventsDefinition.ts @@ -9,6 +9,7 @@ import { } from 'ontime-types'; export const event: Omit = { + type: SupportedEvent.Event, title: '', note: '', endAction: EndAction.None, @@ -22,22 +23,33 @@ export const event: Omit = { isPublic: false, skip: false, colour: '', - type: SupportedEvent.Event, - revision: 0, - delay: 0, - dayOffset: 0, - gap: 0, + currentBlock: null, + revision: 0, // calculated at runtime + delay: 0, // calculated at runtime + dayOffset: 0, // calculated at runtime + gap: 0, // calculated at runtime timeWarning: 120000, timeDanger: 60000, custom: {}, }; export const delay: Omit = { - duration: 0, type: SupportedEvent.Delay, + duration: 0, }; export const block: Omit = { - title: '', type: SupportedEvent.Block, + title: '', + note: '', + events: [], + skip: false, + colour: '', + revision: 0, // calculated at runtime + startTime: null, // calculated at runtime + endTime: null, // calculated at runtime + duration: 0, // calculated at runtime + isFirstLinked: false, // calculated at runtime + numEvents: 0, // calculated at runtime + custom: {}, }; diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 1bf28dba8..36b2217c3 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -18,7 +18,7 @@ import { import { dbModel } from '../../models/dataModel.js'; import { deleteFile } from '../../utils/parserUtils.js'; import { parseDatabaseModel } from '../../utils/parser.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; +import { parseRundowns } from '../../utils/parserFunctions.js'; import { demoDb } from '../../models/demoProject.js'; import { config } from '../../setup/config.js'; import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; @@ -40,6 +40,7 @@ import { moveCorruptFile, parseJsonFile, } from './projectServiceUtils.js'; +import { getFirstRundown } from '../rundown-service/rundownUtils.js'; // init dependencies init(); @@ -83,7 +84,7 @@ async function loadNewProject(): Promise { } /** - * Private function handles side effects on currupted files + * Private function handles side effects on corrupted files * Corrupted files in this context contain data that failed domain validation */ async function handleCorruptedFile(filePath: string, fileName: string): Promise { @@ -176,10 +177,11 @@ export async function loadProjectFile(name: string) { // apply data model runtimeService.stop(); - const { rundown, customFields } = result.data; + const { rundowns, customFields } = result.data; // apply the rundown - await initRundown(rundown, customFields); + const firstRundown = getFirstRundown(rundowns); + await initRundown(firstRundown, customFields); } /** @@ -246,10 +248,11 @@ export async function renameProjectFile(originalFile: string, newFilename: strin // apply data model runtimeService.stop(); - const { rundown, customFields } = result.data; + const { rundowns, customFields } = result.data; // apply the rundown - await initRundown(rundown, customFields); + const firstRundown = getFirstRundown(rundowns); + await initRundown(firstRundown, customFields); } } @@ -300,17 +303,23 @@ export async function patchCurrentProject(data: Partial) { runtimeService.stop(); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging - const { rundown, customFields, ...rest } = data; + const { rundowns, customFields, ...rest } = data; // we can pass some stuff straight to the data provider - const newData = await getDataProvider().mergeIntoData(rest); + await getDataProvider().mergeIntoData(rest); // ... but rundown and custom fields need to be checked - if (rundown != null) { - const result = parseRundown(data); - await initRundown(result.rundown, result.customFields); + if (rundowns != null) { + const result = parseRundowns(data); + /** + * The user may have multiple rundowns + * We currently ignore all other rundowns + */ + const firstRundown = getFirstRundown(result.rundowns); + initRundown(firstRundown, result.customFields); } - return newData; + const updatedData = await getDataProvider().getData(); + return updatedData; } /** diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index b716e5b6d..6b08b721b 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -44,12 +44,12 @@ describe('duplicateProjectFile', () => { await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found'); }); - it('throws an error if new file name is already a project', () => { + it('throws an error if new file name is already a project', async () => { // current project exists (doesProjectExist as Mock).mockReturnValueOnce('thisoneexists'); // new project exists (doesProjectExist as Mock).mockReturnValueOnce('existingproject'); - expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow( + await expect(duplicateProjectFile('thisoneexists', 'existingproject')).rejects.toThrow( 'Project file with name existingproject already exists', ); }); @@ -66,7 +66,7 @@ describe('renameProjectFile', () => { (doesProjectExist as Mock).mockReturnValueOnce('this one exists'); // new project exists (doesProjectExist as Mock).mockReturnValueOnce('existingproject'); - expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow( + await expect(renameProjectFile('this one exists', 'existingproject')).rejects.toThrow( 'Project file with name existingproject already exists', ); }); diff --git a/apps/server/src/services/rundown-service/RundownService.ts b/apps/server/src/services/rundown-service/RundownService.ts index f8efff352..e32e5c7a2 100644 --- a/apps/server/src/services/rundown-service/RundownService.ts +++ b/apps/server/src/services/rundown-service/RundownService.ts @@ -4,13 +4,14 @@ import { OntimeBlock, OntimeDelay, OntimeEvent, - OntimeRundownEntry, + OntimeEntry, isOntimeBlock, isOntimeDelay, isOntimeEvent, - OntimeRundown, PatchWithId, EventPostPayload, + Rundown, + EntryId, } from 'ontime-types'; import { getCueCandidate } from 'ontime-utils'; @@ -22,7 +23,6 @@ import { updateRundownData } from '../../stores/runtimeState.js'; import { runtimeService } from '../runtime-service/RuntimeService.js'; import * as cache from './rundownCache.js'; -import { getPlayableEvents, getTimedEvents } from './rundownUtils.js'; type CompleteEntry = T extends Partial @@ -33,15 +33,23 @@ type CompleteEntry = ? OntimeBlock : never; +/** + * Generates a fully formed RundownEntry of the patch type + */ function generateEvent | Partial | Partial>( eventData: T, afterId?: string, ): CompleteEntry { + // TODO: could we keep the UI ID to avoid the flash on create? // we discard any UI provided IDs and add our own const id = cache.getUniqueId(); if (isOntimeEvent(eventData)) { - return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry; + const currentRundown = cache.getCurrentRundown(); + return createEvent( + eventData, + getCueCandidate(currentRundown.entries, currentRundown.order, afterId), + ) as CompleteEntry; } if (isOntimeDelay(eventData)) { @@ -56,19 +64,17 @@ function generateEvent | Partial | P } /** - * @description creates a new event with given data - * @param {object} eventData - * @return {OntimeRundownEntry} + * creates a new event with given data */ -export async function addEvent(eventData: EventPostPayload): Promise { +export async function addEvent(eventData: EventPostPayload): Promise { // if the user didnt provide an index, we add the event to start let atIndex = 0; let afterId: string | undefined = eventData?.after; - if (eventData?.after !== undefined) { - const previousIndex = cache.getIndexOf(eventData.after); + if (afterId) { + const previousIndex = cache.getIndexOf(afterId); if (previousIndex < 0) { - logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`); + logger.warning(LogOrigin.Server, `Could not find event with id ${afterId}`); } else { atIndex = previousIndex + 1; } @@ -79,7 +85,7 @@ export async function addEvent(eventData: EventPostPayload): Promise 0) { - afterId = cache.getPersistedRundown()[atIndex - 1].id; + afterId = cache.getIdOf(atIndex - 1); } } } @@ -95,14 +101,14 @@ export async function addEvent(eventData: EventPostPayload): Promise, customFields: Readonly) { +export async function initRundown(rundown: Readonly, customFields: Readonly) { await cache.init(rundown, customFields); // notify runtime that rundown has changed diff --git a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts index bff811e2d..7bc16bf75 100644 --- a/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts +++ b/apps/server/src/services/rundown-service/__mocks__/rundown.mocks.ts @@ -1,4 +1,5 @@ -import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types'; +import { SupportedEvent, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types'; +import { defaultRundown } from '../../../models/dataModel.js'; const baseEvent = { type: SupportedEvent.Event, @@ -6,6 +7,10 @@ const baseEvent = { revision: 1, }; +const baseBlock = { + type: SupportedEvent.Block, +}; + /** * Utility to create a Ontime event */ @@ -19,8 +24,25 @@ export function makeOntimeEvent(patch: Partial): OntimeEvent { /** * Utility to create a delay event */ -export function makeOntimeDelay(duration: number): OntimeDelay { - return { id: 'delay', type: SupportedEvent.Delay, duration }; +export function makeOntimeDelay(patch: Partial): OntimeDelay { + return { id: 'delay', type: SupportedEvent.Delay, duration: 0, ...patch } as OntimeDelay; +} + +/** + * Utility to create a block event + */ +export function makeOntimeBlock(patch: Partial): OntimeBlock { + return { id: 'block', ...baseBlock, ...patch } as OntimeBlock; +} + +/** + * Utility to create a rundown object + */ +export function makeRundown(patch: Partial): Rundown { + return { + ...defaultRundown, + ...patch, + }; } /** diff --git a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts index a88bbec3a..5f296e0db 100644 --- a/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/delayUtils.test.ts @@ -1,61 +1,84 @@ -import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types'; +import { OntimeEvent, SupportedEvent } from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { apply } from '../delayUtils.js'; -import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; +import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; describe('apply()', () => { it('applies a positive delay to the rundown', () => { - const testRundown = [ - makeOntimeDelay(10), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), - { id: '3', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), - ]; + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).not.toBe(testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, - { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, - { id: '3' }, - { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, - { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, - ]); + apply('delay', testRundown); + expect(testRundown.revision).toBe(1); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, + '5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, + }); }); it('applies negative delays', () => { - const testRundown = [ - makeOntimeDelay(-10), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), - makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), - { id: '3', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), - makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), - ]; + const testRundown = makeRundown({ + revision: 0, + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -10 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), + '2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), + '3': makeOntimeBlock({ id: '3' }), + '4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), + '5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, - { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, - { id: '3' }, - { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, - { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, - ]); + apply('delay', testRundown); + expect(testRundown.revision).toBe(1); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, + '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, + '3': { id: '3' }, + '4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, + '5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, + }); }); it('should account for minimum duration and start when applying negative delays', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(-50), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), + }, + }); - const expected = [ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent, - { + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + type: SupportedEvent.Event, + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 2, + } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event, timeStart: 50, @@ -63,173 +86,222 @@ describe('apply()', () => { duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]; - - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject(expected); + }, + }); }); it('unlinks events to maintain gaps when applying positive delays', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(50), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('maintains links if there is no gap', () => { - const testRundown = [ - makeOntimeDelay(50), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent, - { + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 50, + timeEnd: 150, + duration: 100, + revision: 2, + }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: '1', revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('unlinks events to maintain gaps when applying negative delays', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(-50), - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; + const testRundown = makeRundown({ + order: ['1', 'delay', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: -50 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 50, timeEnd: 100, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); it('gaps reduce positive delay', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(100), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - // gap 50 - makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), - // gap 0 - makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), - // gap 50 - makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), - // linked - makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1', '2', '3', '4', '5'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + // gap 50 + '2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), + // gap 0 + '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), + // gap 50 + '4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), + // linked + '5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, // gap 50 (100 - 50) - { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, + '2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 }, // gap 50 (50 - 50) - { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, + '3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 }, // gap (delay is 0) - { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, + '4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 }, // linked - { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, - ]); + '5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: '4' }, + }); }); it('gaps reduce positive delay (2)', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(2 * MILLIS_PER_HOUR), - makeOntimeEvent({ - id: '1', - gap: 0, - dayOffset: 0, - timeStart: 46800000, // 13:00:00 - timeEnd: 50400000, // 14:00:00 - duration: MILLIS_PER_HOUR, - }), - // gap 1h - makeOntimeEvent({ - id: '2', - gap: 1 * MILLIS_PER_HOUR, - dayOffset: 0, - timeStart: 54000000, // 15:00:00 - timeEnd: 57600000, // 16:00:00 - duration: MILLIS_PER_HOUR, - }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1', '2'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }), + '1': makeOntimeEvent({ + id: '1', + gap: 0, + dayOffset: 0, + timeStart: 46800000, // 13:00:00 + timeEnd: 50400000, // 14:00:00 + duration: MILLIS_PER_HOUR, + }), + // gap 1h + '2': makeOntimeEvent({ + id: '2', + gap: 1 * MILLIS_PER_HOUR, + dayOffset: 0, + timeStart: 54000000, // 15:00:00 + timeEnd: 57600000, // 16:00:00 + duration: MILLIS_PER_HOUR, + }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([ - { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', '2']); + expect(testRundown.entries).toMatchObject({ + '1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, // gap 1h (2h - 1h) - { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, - ]); + '2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 }, + }); }); it('removes empty delays without applying changes', () => { - const testRundown: OntimeRundown = [ - makeOntimeDelay(0), - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - ]; + const testRundown = makeRundown({ + order: ['delay', '1'], + entries: { + delay: makeOntimeDelay({ id: 'delay', duration: 0 }), + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1']); + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); }); it('removes delays in last position without applying changes', () => { - const testRundown: OntimeRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), - makeOntimeDelay(100), - ]; + const testRundown = makeRundown({ + order: ['1', 'delay'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 100 }), + }, + }); - const updatedRundown = apply('delay', testRundown); - expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1']); + expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } }); }); it('unlinks events to across blocks is it is the first event after the delay', () => { - const testRundown = [ - makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), - makeOntimeDelay(50), - { id: 'block', type: SupportedEvent.Block } as OntimeBlock, - makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), - ]; - expect(apply('delay', testRundown)).toMatchObject([ - { id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, - { id: 'block', type: SupportedEvent.Block }, - { + const testRundown = makeRundown({ + order: ['1', 'delay', 'block', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), + delay: makeOntimeDelay({ id: 'delay', duration: 50 }), + block: makeOntimeBlock({ id: 'block' }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), + }, + }); + + apply('delay', testRundown); + expect(testRundown.order).toMatchObject(['1', 'block', '2']); + + expect(testRundown.entries).toMatchObject({ + '1': { + id: '1', + timeStart: 0, + timeEnd: 100, + duration: 100, + revision: 1, + }, + block: { id: 'block' }, + '2': { id: '2', - type: SupportedEvent.Event, timeStart: 150, timeEnd: 200, duration: 50, linkStart: null, revision: 2, - } as OntimeEvent, - ]); + }, + }); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts index ce4dbec6a..399c36baf 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCache.test.ts @@ -1,13 +1,4 @@ -import { - CustomFields, - EventCustomFields, - OntimeBlock, - OntimeDelay, - OntimeEvent, - OntimeRundown, - SupportedEvent, - TimeStrategy, -} from 'ontime-types'; +import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; import { @@ -23,6 +14,7 @@ import { removeCustomField, customFieldChangelog, } from '../rundownCache.js'; +import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js'; beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -39,13 +31,16 @@ beforeAll(() => { describe('generate()', () => { it('creates normalised versions of a given rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1' } as OntimeEvent, - { type: SupportedEvent.Block, id: '2' } as OntimeBlock, - { type: SupportedEvent.Delay, id: '3' } as OntimeDelay, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeBlock({ id: '2' }), + '3': makeOntimeDelay({ id: '3' }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(3); expect(initResult.order).toStrictEqual(['1', '2', '3']); expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event); @@ -54,29 +49,35 @@ describe('generate()', () => { }); it('calculates delays versions of a given rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 1, timeEnd: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeDelay({ id: '1', duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 1, timeEnd: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(2); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100); expect(initResult.totalDelay).toBe(100); }); it('accounts for gaps in rundown when calculating delays', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: 200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(7); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200); @@ -87,76 +88,84 @@ describe('generate()', () => { }); it('accounts for overlaps in rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start }); it('accounts for overlaps in rundown (with added gap)', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, - { type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }), + '2': makeOntimeEvent({ id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 }), + '3': makeOntimeEvent({ id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 }), + '4': makeOntimeEvent({ id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start }); it('accounts for overlaps in rundown (with multiple days)', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, - timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, - duration: 30 * MILLIS_PER_MINUTE, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '4', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 10 * MILLIS_PER_HOUR, - duration: MILLIS_PER_HOUR, - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3', '4'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, + timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, + duration: 30 * MILLIS_PER_MINUTE, + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, + duration: MILLIS_PER_HOUR, + }), + '4': makeOntimeEvent({ + id: '4', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 10 * MILLIS_PER_HOUR, + duration: MILLIS_PER_HOUR, + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start }); it('handles negative delays', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, - { type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + delay: makeOntimeDelay({ id: 'delay', duration: -200 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + block: makeOntimeBlock({ id: 'block', title: 'break' }), + '3': makeOntimeEvent({ id: '3', timeStart: 400, timeEnd: 500, duration: 100 }), + 'another-block': makeOntimeBlock({ id: 'another-block', title: 'another-break' }), + '4': makeOntimeEvent({ id: '4', timeStart: 600, timeEnd: 700, duration: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(7); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200); @@ -167,38 +176,38 @@ describe('generate()', () => { }); it('links times across events', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 1, - duration: 1, - timeEnd: 2, - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 11, - duration: 1, - timeEnd: 12, - linkStart: '1', - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - { type: SupportedEvent.Block, id: 'block' } as OntimeBlock, - { type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 21, - duration: 1, - timeEnd: 22, - linkStart: '2', - timeStrategy: TimeStrategy.LockEnd, - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', 'block', 'delay', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 1, + duration: 1, + timeEnd: 2, + timeStrategy: TimeStrategy.LockEnd, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 11, + duration: 1, + timeEnd: 12, + linkStart: '1', + timeStrategy: TimeStrategy.LockEnd, + }), + block: makeOntimeBlock({ id: 'block' }), + delay: makeOntimeDelay({ id: 'delay' }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 21, + duration: 1, + timeEnd: 22, + linkStart: '2', + timeStrategy: TimeStrategy.LockEnd, + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(5); expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2); expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12); @@ -213,13 +222,16 @@ describe('generate()', () => { }); it('links times across events, reordered', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '3', '2'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 1, timeEnd: 2 }), + '3': makeOntimeEvent({ id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' }), + '2': makeOntimeEvent({ id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(3); expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2); expect(initResult.links['1']).toBe('3'); @@ -227,159 +239,156 @@ describe('generate()', () => { }); it('calculates total duration', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'skipped', - skip: true, - timeStart: 300, - timeEnd: 400, - duration: 100, - } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); it('calculates total duration with 0 duration events without causing a next day', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent, - { type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'skipped', - skip: true, - timeStart: 300, - timeEnd: 400, - duration: 0, - } as OntimeEvent, - { type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', 'skipped', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }), + '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }), + skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }), + '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.order.length).toBe(4); expect(initResult.totalDuration).toBe(500 - 100); }); it('calculates total duration across days with gap', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '3', - timeStart: 9 * MILLIS_PER_HOUR, - timeEnd: 23 * MILLIS_PER_HOUR, - duration: (23 - 9) * MILLIS_PER_HOUR, - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + '3': makeOntimeEvent({ + id: '2', + timeStart: 9 * MILLIS_PER_HOUR, + timeEnd: 23 * MILLIS_PER_HOUR, + duration: (23 - 9) * MILLIS_PER_HOUR, + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); }); it('calculates total duration across days', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - timeStart: 12 * MILLIS_PER_HOUR, - timeEnd: 22 * MILLIS_PER_HOUR, - duration: 10 * MILLIS_PER_HOUR, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - timeStart: 22 * MILLIS_PER_HOUR, - timeEnd: 8 * MILLIS_PER_HOUR, - duration: (24 - 22 + 8) * MILLIS_PER_HOUR, - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 12 * MILLIS_PER_HOUR, + timeEnd: 22 * MILLIS_PER_HOUR, + duration: 10 * MILLIS_PER_HOUR, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 22 * MILLIS_PER_HOUR, + timeEnd: 8 * MILLIS_PER_HOUR, + duration: (24 - 22 + 8) * MILLIS_PER_HOUR, + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); expect(initResult.totalDuration).toBe(expectedDuration); }); it('handles updating event sequence', () => { - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '97cc3e', - timeStart: 0, - timeEnd: 600000, - duration: 600000, - timeStrategy: TimeStrategy.LockDuration, - linkStart: null, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: 'e01948', - timeStart: 600000, - timeEnd: 601000, - duration: 85801000, // <------------- value out of sync - timeStrategy: TimeStrategy.LockEnd, - linkStart: '97cc3e', - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '25c1af', - timeStart: 100, // <------------- value out of sync - timeEnd: 602000, - duration: 0, - timeStrategy: TimeStrategy.LockEnd, - linkStart: 'e01948', - } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ + id: '1', + timeStart: 0, + timeEnd: 600000, + duration: 600000, + timeStrategy: TimeStrategy.LockDuration, + linkStart: null, + }), + '2': makeOntimeEvent({ + id: '2', + timeStart: 600000, + timeEnd: 601000, + duration: 85801000, // <------------- value out of sync + timeStrategy: TimeStrategy.LockEnd, + linkStart: '1', + }), + '3': makeOntimeEvent({ + id: '3', + timeStart: 100, // <------------- value out of sync + timeEnd: 602000, + duration: 0, + timeStrategy: TimeStrategy.LockEnd, + linkStart: '2', + }), + }, + }); - const initResult = generate(testRundown); + const initResult = generate(rundown); expect(initResult.rundown).toMatchObject({ - '97cc3e': { + '1': { timeStart: 0, timeEnd: 600000, duration: 600000, timeStrategy: 'lock-duration', linkStart: null, }, - e01948: { + '2': { timeStart: 600000, timeEnd: 601000, duration: 1000, timeStrategy: 'lock-end', - linkStart: '97cc3e', + linkStart: '1', }, - '25c1af': { + '3': { timeStart: 601000, timeEnd: 602000, duration: 1000, timeStrategy: 'lock-end', - linkStart: 'e01948', + linkStart: '2', }, }); }); it('deletes links if invalid', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent, - ]; - const initResult = generate(testRundown); + const rundown = makeRundown({ + order: ['1'], + entries: { + '1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }), + }, + }); + + const initResult = generate(rundown); expect(initResult.order.length).toBe(1); expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1); expect(Object.keys(initResult.links).length).toBe(0); @@ -399,24 +408,26 @@ describe('generate()', () => { colour: 'red', }, }; - const testRundown: OntimeRundown = [ - { - type: SupportedEvent.Event, - id: '1', - custom: { - lighting: 'event 1 lx', - } as EventCustomFields, - } as OntimeEvent, - { - type: SupportedEvent.Event, - id: '2', - custom: { - lighting: 'event 2 lx', - sound: 'event 2 sound', - } as EventCustomFields, - } as OntimeEvent, - ]; - const initResult = generate(testRundown, customProperties); + + const rundown = makeRundown({ + order: ['1', '2'], + entries: { + '1': makeOntimeEvent({ + id: '1', + custom: { + lighting: 'event 1 lx', + }, + }), + '2': makeOntimeEvent({ + id: '2', + custom: { + lighting: 'event 2 lx', + sound: 'event 2 sound', + }, + }), + }, + }); + const initResult = generate(rundown, customProperties); expect(initResult.order.length).toBe(2); expect(initResult.assignedCustomFields).toMatchObject({ lighting: ['1', '2'], @@ -433,47 +444,64 @@ describe('generate()', () => { describe('add() mutation', () => { test('adds an event to the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const testRundown: OntimeRundown = []; - const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown }); - expect(newRundown.length).toBe(1); - expect(newRundown[0]).toMatchObject(mockEvent); + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({}); + const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown }); + expect(newRundown.order.length).toBe(1); + expect(newRundown.entries['mock']).toMatchObject(mockEvent); }); }); describe('remove() mutation', () => { test('deletes an event from the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const testRundown: OntimeRundown = [mockEvent]; - const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown }); - expect(newRundown.length).toBe(0); + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const rundown = makeRundown({ + order: ['mock'], + entries: { + mock: mockEvent, + }, + }); + + const { newRundown } = remove({ eventIds: [mockEvent.id], rundown }); + expect(newRundown.order.length).toBe(0); }); + test('deletes multiple events from the rundown', () => { - const testRundown: OntimeRundown = [ - { type: SupportedEvent.Event, id: '1' } as OntimeEvent, - { type: SupportedEvent.Block, id: '2' } as OntimeBlock, - { type: SupportedEvent.Delay, id: '3' } as OntimeDelay, - { type: SupportedEvent.Event, id: '4' } as OntimeEvent, - { type: SupportedEvent.Event, id: '5' } as OntimeEvent, - { type: SupportedEvent.Event, id: '6' } as OntimeEvent, - ]; - const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown }); - expect(newRundown.length).toBe(3); - expect(newRundown.at(0)?.id).toBe('4'); + const rundown = makeRundown({ + order: ['1', '2', '3', '4', '5', '6'], + entries: { + '1': makeOntimeEvent({ id: '1' }), + '2': makeOntimeBlock({ id: '2' }), + '3': makeOntimeDelay({ id: '3' }), + '4': makeOntimeEvent({ id: '4' }), + '5': makeOntimeEvent({ id: '5' }), + '6': makeOntimeEvent({ id: '6' }), + }, + }); + + const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown }); + expect(newRundown.order.length).toBe(3); + expect(newRundown.entries[newRundown.order[0]].id).toBe('4'); }); }); describe('edit() mutation', () => { test('edits an event in the rundown', () => { - const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; - const mockEventPatch = { cue: 'patched' } as OntimeEvent; - const testRundown: OntimeRundown = [mockEvent]; + const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' }); + const mockEventPatch = makeOntimeEvent({ cue: 'patched' }); + const rundown = makeRundown({ + order: ['mock'], + entries: { + mock: mockEvent, + }, + }); + const { newRundown, newEvent } = edit({ eventId: mockEvent.id, patch: mockEventPatch, - rundown: testRundown, + rundown, }); - expect(newRundown.length).toBe(1); + expect(newRundown.order.length).toBe(1); expect(newEvent).toMatchObject({ id: 'mock', cue: 'patched', @@ -484,73 +512,96 @@ describe('edit() mutation', () => { describe('batchEdit() mutation', () => { it('should correctly apply the patch to the events with the given IDs', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent, - ]; + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1' }), + '2': makeOntimeEvent({ id: '2', cue: 'data2' }), + '3': makeOntimeEvent({ id: '3', cue: 'data3' }), + }, + }); + const eventIds = ['1', '3']; const patch = { cue: 'newData' }; - const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch }); + const { newRundown } = batchEdit({ rundown, eventIds, patch }); - expect(newRundown).toMatchObject([ - { id: '1', type: SupportedEvent.Event, cue: 'newData' }, - { id: '2', type: SupportedEvent.Event, cue: 'data2' }, - { id: '3', type: SupportedEvent.Event, cue: 'newData' }, - ]); + expect(newRundown.entries).toMatchObject({ + '1': { id: '1', type: SupportedEvent.Event, cue: 'newData' }, + '2': { id: '2', type: SupportedEvent.Event, cue: 'data2' }, + '3': { id: '3', type: SupportedEvent.Event, cue: 'newData' }, + }); }); }); describe('reorder() mutation', () => { it('should correctly reorder two events', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent, - ]; - const { newRundown } = reorder({ - rundown: testRundown, - eventId: testRundown[0].id, - from: 0, - to: testRundown.length - 1, + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }), + '2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }), + '3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }), + }, }); - 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 }, - ]); + // move first event to the end + const { newRundown } = reorder({ + rundown: rundown, + eventId: rundown.order[0], + from: 0, + to: rundown.order.length - 1, + }); + + expect(newRundown.order).toStrictEqual(['2', '3', '1']); + expect(newRundown.entries).toMatchObject({ + '2': { id: '2', cue: 'data2', revision: 1 }, + '3': { id: '3', cue: 'data3', revision: 1 }, + '1': { id: '1', cue: 'data1', revision: 1 }, + }); }); }); describe('swap() mutation', () => { it('should correctly swap data between events', () => { - const testRundown: OntimeRundown = [ - { id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent, - { id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent, - { id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent, - ]; - const { newRundown } = swap({ - rundown: testRundown, - fromId: testRundown[0].id, - toId: testRundown[1].id, + const rundown = makeRundown({ + order: ['1', '2', '3'], + entries: { + '1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1, revision: 4 }), + '2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2, revision: 8 }), + '3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3, revision: 12 }), + }, }); - 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); + // swap first and second event + const { newRundown } = swap({ + rundown: rundown, + fromId: rundown.order[0], + toId: rundown.order[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.order).toStrictEqual(['1', '2', '3']); - expect((newRundown[2] as OntimeEvent).id).toBe('3'); - expect((newRundown[2] as OntimeEvent).cue).toBe('data3'); - expect((newRundown[2] as OntimeEvent).timeStart).toBe(3); - expect((newRundown[2] as OntimeEvent).revision).toBe(0); + expect(newRundown.entries['1']).toMatchObject({ + id: '1', + cue: 'data2', + timeStart: 1, + revision: 5, + }); + + expect(newRundown.entries['2']).toMatchObject({ + id: '2', + cue: 'data1', + timeStart: 2, + revision: 9, + }); + + expect(newRundown.entries['3']).toMatchObject({ + id: '3', + cue: 'data3', + timeStart: 3, + revision: 12, + }); }); }); diff --git a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts index 5904e38fd..a8f4b832b 100644 --- a/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts +++ b/apps/server/src/services/rundown-service/__tests__/rundownCacheUtils.test.ts @@ -2,7 +2,7 @@ import { CustomFields, EndAction, OntimeEvent, - OntimeRundown, + RundownEntries, SupportedEvent, TimeStrategy, TimerType, @@ -10,46 +10,25 @@ import { import { addToCustomAssignment, calculateDayOffset, - getLink, handleCustomField, handleLink, hasChanges, isDataStale, } from '../rundownCacheUtils.js'; import { MILLIS_PER_HOUR } from 'ontime-utils'; - -describe('getLink()', () => { - it('should return null if there is no link', () => { - const rundown = [ - { type: SupportedEvent.Block, id: 'block' }, - { type: SupportedEvent.Event, id: '1' }, - ] as OntimeRundown; - - const result = getLink(1, rundown); - expect(result).toBeNull(); - }); - - it('returns previous event', () => { - const rundown = [ - { type: SupportedEvent.Event, id: '1', timeEnd: 100 }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - - const result = getLink(1, rundown); - expect(result.id).toBe('1'); - }); -}); +import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js'; describe('handleLink()', () => { it('populates data in object and updates link map', () => { - const rundown = [ - { type: SupportedEvent.Event, id: '1', timeEnd: 100 }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - const mutableEvent = { ...rundown[1] } as OntimeEvent; + const entries: RundownEntries = { + '1': makeOntimeEvent({ id: '1', timeEnd: 100 }), + '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), + }; + + const mutableEvent = { ...entries[2] } as OntimeEvent; const links = {}; - const result = handleLink(1, rundown, mutableEvent, links); + const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links); expect(result).toBeUndefined(); expect(mutableEvent.timeStart).toBe(100); expect(mutableEvent.linkStart).toBe('1'); @@ -57,17 +36,17 @@ describe('handleLink()', () => { }); it('removes link if linked event is not found', () => { - const rundown = [ - { type: SupportedEvent.Block, id: '1' }, - { type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, - ] as OntimeRundown; - const mutableEvent = { ...rundown[1] } as OntimeEvent; + const entries: RundownEntries = { + '1': makeOntimeBlock({ id: '1' }), + '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }), + }; + const mutableEvent = { ...entries[2] } as OntimeEvent; const links = {}; - const result = handleLink(1, rundown, mutableEvent, links); + const result = handleLink(mutableEvent, null, links); expect(result).toBeUndefined(); expect(mutableEvent.timeStart).toBe(0); - expect(mutableEvent.linkStart).toBe(null); + expect(mutableEvent.linkStart).toBe('true'); expect(links).toStrictEqual({}); }); }); @@ -252,7 +231,7 @@ describe('hasChanges()', () => { describe('calculateDayOffset', () => { it('returns 0 if there is no previous event', () => { - expect(calculateDayOffset({ timeStart: 0 })).toBe(0); + expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0); }); it('returns 0 if the previous event duration is 0', () => { diff --git a/apps/server/src/services/rundown-service/delayUtils.ts b/apps/server/src/services/rundown-service/delayUtils.ts index f5264dc4d..8d0d7b462 100644 --- a/apps/server/src/services/rundown-service/delayUtils.ts +++ b/apps/server/src/services/rundown-service/delayUtils.ts @@ -1,38 +1,41 @@ -import { OntimeRundown, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; +import { Rundown, EntryId, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types'; import { deleteAtIndex } from 'ontime-utils'; /** * Applies delay from given event ID, deletes the delay event after - * @throws {Error} if event ID not found or is not a delay + * Mutates the given rundown + * @throws if event ID not found or is not a delay */ -export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { - const delayIndex = rundown.findIndex((event) => event.id === eventId); - const delayEvent = rundown.at(delayIndex); +export function apply(delayId: EntryId, rundown: Rundown): Rundown { + const delayEvent = rundown.entries[delayId]; - if (!delayEvent) { - throw new Error('Given event ID not found'); + if (!delayEvent || !isOntimeDelay(delayEvent)) { + throw new Error('Given delay ID not found'); } - if (!isOntimeDelay(delayEvent)) { - throw new Error('Given event ID is not a delay'); - } + const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId); - // if the delay is empty, or the last element, we can just delete it - if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) { - return deleteAtIndex(delayIndex, rundown); + // if the delay is empty, or the last element + // we can just delete it with no further operations + if (delayEvent.duration === 0 || delayIndex === rundown.order.length - 1) { + delete rundown.entries[delayId]; + rundown.order = deleteAtIndex(delayIndex, rundown.order); + return rundown; } /** * We apply the delay to the rundown * This logic is mostly in sync with rundownCache.generate + * The difference is that here it will become part of the schedule, + * so we cant leave the work for the generate function */ - const updatedRundown = structuredClone(rundown); let delayValue = delayEvent.duration; let lastEntry: OntimeEvent | null = null; let isFirstEvent = true; - for (let i = delayIndex + 1; i < updatedRundown.length; i++) { - const currentEntry = updatedRundown[i]; + for (let i = delayIndex + 1; i < rundown.order.length; i++) { + const currentId = rundown.order[i]; + const currentEntry = rundown.entries[currentId]; // we don't do operation on other event types if (!isOntimeEvent(currentEntry)) { @@ -77,5 +80,9 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown { currentEntry.revision += 1; } - return deleteAtIndex(delayIndex, updatedRundown); + delete rundown.entries[delayId]; + rundown.order = deleteAtIndex(delayIndex, rundown.order); + rundown.revision += 1; + + return rundown; } diff --git a/apps/server/src/services/rundown-service/rundownCache.ts b/apps/server/src/services/rundown-service/rundownCache.ts index 954c507ce..5a2ce87c6 100644 --- a/apps/server/src/services/rundown-service/rundownCache.ts +++ b/apps/server/src/services/rundown-service/rundownCache.ts @@ -2,15 +2,18 @@ import { CustomField, CustomFieldLabel, CustomFields, + EntryId, isOntimeBlock, isOntimeDelay, isOntimeEvent, isPlayableEvent, MaybeNumber, + OntimeBlock, OntimeEvent, - OntimeRundown, - OntimeRundownEntry, + OntimeEntry, PlayableEvent, + Rundown, + RundownEntries, } from 'ontime-types'; import { generateId, @@ -21,26 +24,32 @@ import { isNewLatest, customFieldLabelToKey, } from 'ontime-utils'; + import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { createPatch } from '../../utils/parser.js'; + import { apply } from './delayUtils.js'; import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js'; -type EventID = string; -type NormalisedRundown = Record; - -let persistedRundown: OntimeRundown = []; +let currentRundownId: EntryId = ''; +let currentRundown: Rundown = { + id: '', + title: '', + order: [], + entries: {}, + revision: 0, +}; let persistedCustomFields: CustomFields = {}; /** * Get the cached rundown without triggering regeneration */ -export const getPersistedRundown = (): OntimeRundown => persistedRundown; +export const getCurrentRundown = (): Rundown => currentRundown; export const getCustomFields = (): CustomFields => persistedCustomFields; -let normalisedRundown: NormalisedRundown = {}; -let order: EventID[] = []; -let revision = 0; +let playableEventsOrder: EntryId[] = []; +let timedEventsOrder: EntryId[] = []; +let flatIndexOrder: EntryId[] = []; /** * all mutating functions will set this value if there is a need for re-generation @@ -59,7 +68,7 @@ let totalDays = 0; let firstStart: MaybeNumber = null; let lastEnd: MaybeNumber = null; -let links: Record = {}; +let links: Record = {}; /** * Object that contains reference of renamed custom fields @@ -76,13 +85,17 @@ export const customFieldChangelog = new Map(); * Keep track of which custom fields are used. * This will be handy for when we delete custom fields */ -let assignedCustomFields: Record = {}; +let assignedCustomFields: Record = {}; -export async function init(initialRundown: Readonly, customFields: Readonly) { - persistedRundown = structuredClone(initialRundown) as OntimeRundown; +/** + * Receives a rundown which will be processed and used as the new current rundown + */ +export async function init(initialRundown: Rundown, customFields: Readonly) { + currentRundown = structuredClone(initialRundown); + currentRundownId = initialRundown.id; persistedCustomFields = structuredClone(customFields); generate(); - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); await getDataProvider().setCustomFields(customFields); } @@ -90,10 +103,7 @@ export async function init(initialRundown: Readonly, customFields * Utility generate cache * @private should not be called outside of `rundownCache.ts` */ -export function generate( - initialRundown: OntimeRundown = persistedRundown, - customFields: CustomFields = persistedCustomFields, -) { +export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) { function clearIsStale() { isStale = false; } @@ -102,8 +112,10 @@ export function generate( // instead of maintaining logic to update it assignedCustomFields = {}; - normalisedRundown = {}; - order = []; + playableEventsOrder = []; + timedEventsOrder = []; + flatIndexOrder = []; + links = {}; firstStart = null; lastEnd = null; @@ -111,20 +123,30 @@ export function generate( totalDays = 0; totalDelay = 0; + // temporary parsed rundown + const parsedEntries: RundownEntries = {}; + const parsedOrder: EntryId[] = []; + + /** A playableEvent from the previous iteration */ + let previousEntry: PlayableEvent | null = null; + /** The playableEvent most forwards in time processed so far */ let lastEntry: PlayableEvent | null = null; - for (let i = 0; i < initialRundown.length; i++) { + for (let i = 0; i < initialRundown.order.length; i++) { // we assign a reference to the current entry, this will be mutated in place - const currentEntry = initialRundown[i]; + const currentEntryId = initialRundown.order[i]; + const currentEntry = initialRundown.entries[currentEntryId]; + flatIndexOrder.push(currentEntryId); if (isOntimeEvent(currentEntry)) { currentEntry.delay = 0; currentEntry.gap = 0; + timedEventsOrder.push(currentEntryId); - // 1. handle links - mutates updatedEvent - handleLink(i, initialRundown, currentEntry, links); + // 1. handle links - mutates currentEntry and links + handleLink(currentEntry, previousEntry, links); - // 2. handle custom fields - mutates updatedEvent + // 2. handle custom fields - mutates currentEntry handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields); totalDays += calculateDayOffset(currentEntry, lastEntry); @@ -132,6 +154,7 @@ export function generate( // update rundown metadata, it only concerns playable events if (isPlayableEvent(currentEntry)) { + playableEventsOrder.push(currentEntryId); // fist start is always the first event if (firstStart === null) { firstStart = currentEntry.timeStart; @@ -160,6 +183,7 @@ export function generate( // current event delay is the current accumulated delay currentEntry.delay = totalDelay; + previousEntry = currentEntry; // lastEntry is the event with the latest end time if (isNewLatest(currentEntry, lastEntry)) { lastEntry = currentEntry; @@ -178,17 +202,21 @@ export function generate( } // add id to order - order.push(currentEntry.id); + parsedOrder.push(currentEntry.id); // add entry to rundown - normalisedRundown[currentEntry.id] = currentEntry; + parsedEntries[currentEntry.id] = currentEntry; } lastEnd = lastEntry?.timeEnd ?? null; clearIsStale(); customFieldChangelog.clear(); - //The return value is used for testing - return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields }; + // update the cache values + currentRundown.entries = parsedEntries; + currentRundown.order = parsedOrder; + + // The return value is used for testing + return { rundown: parsedEntries, order: parsedOrder, links, totalDelay, totalDuration, assignedCustomFields }; } /** Returns an ID guaranteed to be unique */ @@ -199,21 +227,31 @@ export function getUniqueId(): string { let id = ''; do { id = generateId(); - } while (Object.hasOwn(normalisedRundown, id)); + } while (Object.hasOwn(currentRundown.entries, id)); return id; } /** Returns index of an event with a given id */ -export function getIndexOf(eventId: string) { +export function getIndexOf(eventId: EntryId) { if (isStale) { generate(); } - return order.indexOf(eventId); + return currentRundown.order.indexOf(eventId); +} + +/** Returns id of an event at a given index */ +export function getIdOf(index: number) { + if (isStale) { + generate(); + } + return currentRundown.order.at(index); } type RundownCache = { - rundown: NormalisedRundown; - order: string[]; + id: string; + title: string; + order: EntryId[]; + entries: RundownEntries; revision: number; totalDelay: number; totalDuration: number; @@ -228,19 +266,29 @@ export function get(): Readonly { generate(); } return { - rundown: normalisedRundown, - order, - revision, + id: currentRundown.id, + title: currentRundown.title, + entries: currentRundown.entries, + order: currentRundown.order, + revision: currentRundown.revision, totalDelay, totalDuration, }; } +export type RundownMetadata = { + firstStart: MaybeNumber; + lastEnd: MaybeNumber; + totalDelay: number; + totalDuration: number; + revision: number; +}; + /** * Returns calculated metadata from rundown * Will triggering regeneration if data is stale. */ -export function getMetadata() { +export function getMetadata(): Readonly { if (isStale) { generate(); } @@ -250,15 +298,35 @@ export function getMetadata() { lastEnd, totalDelay, totalDuration, - revision, + revision: currentRundown.revision, }; } -type CommonParams = { rundown: OntimeRundown }; +export type RundownOrder = { + order: EntryId[]; + timedEventsOrder: EntryId[]; + playableEventsOrder: EntryId[]; +}; + +/** + * Exposes the order of events + */ +export function getEventOrder(): Readonly { + if (isStale) { + generate(); + } + return { + order: currentRundown.order, + timedEventsOrder, + playableEventsOrder, + }; +} + +type CommonParams = { rundown: Rundown }; type MutationParams = T & CommonParams; type MutatingReturn = { - newRundown: OntimeRundown; - newEvent?: OntimeRundownEntry; + newRundown: Rundown; + newEvent?: OntimeEntry; didMutate: boolean; }; type MutatingFn = (params: MutationParams) => MutatingReturn; @@ -269,15 +337,17 @@ type MutatingFn = (params: MutationParams) => MutatingRetur */ export function mutateCache(mutation: MutatingFn) { function scopedMutation(params: T) { - const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown }); + // we work on a copy of the rundown + const rundownCopy = structuredClone(currentRundown); + const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: rundownCopy }); // early return without calling side effects if (!didMutate) { return { newEvent, newRundown, didMutate }; } - revision = revision + 1; - persistedRundown = newRundown; + newRundown.revision += 1; + currentRundown = newRundown; // schedule a non priority cache update setImmediate(() => { @@ -286,7 +356,7 @@ export function mutateCache(mutation: MutatingFn) { // defer writing to the database setImmediate(async () => { - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); }); return { newEvent, newRundown, didMutate }; @@ -295,70 +365,91 @@ export function mutateCache(mutation: MutatingFn) { return scopedMutation; } -type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>; +type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>; /** * Add entry to rundown */ export function add({ rundown, atIndex, event }: AddArgs): Required { - const newEvent: OntimeRundownEntry = { ...event }; - const newRundown = insertAtIndex(atIndex, newEvent, rundown); + const newEvent: OntimeEntry = { ...event }; + + rundown.entries[newEvent.id] = newEvent; + rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order); setIsStale(); - return { newRundown, newEvent, didMutate: true }; + return { newRundown: rundown, newEvent, didMutate: true }; } -type RemoveArgs = MutationParams<{ eventIds: string[] }>; +type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>; /** * Remove entry to rundown */ export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { - const newRundown = rundown.filter((event) => !eventIds.includes(event.id)); - const didMutate = rundown.length !== newRundown.length; + const previousLength = rundown.order.length; + rundown.order = rundown.order.filter((id) => !eventIds.includes(id)); + for (const id of eventIds) { + delete rundown.entries[id]; + } + const didMutate = rundown.order.length !== previousLength; if (didMutate) setIsStale(); - return { newRundown, didMutate }; + return { newRundown: rundown, didMutate }; } export function removeAll(): MutatingReturn { setIsStale(); - return { newRundown: [], didMutate: true }; + return { + newRundown: { + id: '', + title: '', + order: [], + entries: {}, + revision: 0, + }, + didMutate: true, + }; } /** * Utility function for patching an existing event with new data */ -function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial): OntimeRundownEntry { +function makeEvent(eventFromRundown: T, patch: Partial): T { if (isOntimeEvent(eventFromRundown)) { - const newEvent = createPatch(eventFromRundown, patch as OntimeEvent); + const newEvent = createPatch(eventFromRundown, patch as Partial); newEvent.revision++; - return newEvent; + return newEvent as T; } - // TODO: exhaustive check - return { ...eventFromRundown, ...patch } as OntimeRundownEntry; + if (isOntimeBlock(eventFromRundown)) { + const newEvent: OntimeBlock = { ...eventFromRundown, ...patch }; + newEvent.revision++; + return newEvent as T; + } + + return { ...eventFromRundown, ...patch } as T; } -type EditArgs = MutationParams<{ eventId: string; patch: Partial }>; +type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial }>; /** * Apply patch to an entry with given id */ export function edit({ rundown, eventId, patch }: EditArgs): Required { - const indexAt = rundown.findIndex((event) => event.id === eventId); - if (indexAt < 0) { - throw new Error('Event not found'); + const entry = rundown.entries[eventId]; + if (!entry) { + // there should be no reason for the entry not to be found + // check if it exists in the rundown order + rundown.order = rundown.order.filter((id) => id !== eventId); + throw new Error('Entry not found'); } - if (patch?.type && rundown[indexAt].type !== patch.type) { + // we cannot allow patching to a different type + if (patch?.type && entry.type !== patch.type) { throw new Error('Invalid event type'); } - const eventInMemory = rundown[indexAt]; - - if (!hasChanges(eventInMemory, patch)) { - return { newRundown: rundown, newEvent: eventInMemory, didMutate: false }; + // if nothing changed, nothing to do + if (!hasChanges(entry, patch)) { + return { newRundown: rundown, newEvent: entry, didMutate: false }; } - const newEvent = makeEvent(eventInMemory, patch); - - const newRundown = [...rundown]; - newRundown[indexAt] = newEvent; + const newEvent = makeEvent(entry, patch); + rundown.entries[newEvent.id] = newEvent; // check whether the data warrants recalculation of cache const makeStale = isDataStale(patch); @@ -366,91 +457,77 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required }>; +type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial }>; /** * Apply patch to multiple entries */ export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn { - const ids = new Set(eventIds); - - const newRundown = []; - for (let i = 0; i < rundown.length; i++) { - if (ids.has(rundown[i].id)) { - if (patch?.type && rundown[i].type !== patch.type) { - continue; - } - const newEvent = makeEvent(rundown[i], patch); - newRundown.push(newEvent); - } else { - newRundown.push(rundown[i]); - } + for (const eventId of eventIds) { + edit({ rundown, eventId, patch }); } - setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } -type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; +type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>; /** - * Redorder two entries + * Reorder two entries */ export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required { - const event = rundown[from]; - if (!event || eventId !== event.id) { + const eventFrom = rundown.entries[eventId]; + if (!eventFrom) { throw new Error('Event not found'); } - const newRundown = reorderArray(rundown, from, to); + rundown.order = reorderArray(rundown.order, from, to); + + // increment revision of all events in between for (let i = from; i <= to; i++) { - const event = newRundown.at(i); - if (isOntimeEvent(event)) { - event.revision += 1; + const eventId = rundown.order[i]; + const entry = rundown.entries[eventId]; + if (isOntimeEvent(entry) || isOntimeBlock(entry)) { + entry.revision += 1; } } + setIsStale(); - return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true }; + return { newRundown: rundown, newEvent: eventFrom, didMutate: true }; } -type ApplyDelayArgs = MutationParams<{ eventId: string }>; +type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>; /** * Apply a delay */ -export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn { - const newRundown = apply(eventId, rundown); +export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn { + apply(delayId, rundown); setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } -type SwapArgs = MutationParams<{ fromId: string; toId: string }>; +type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>; /** * Swap two entries */ export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { - const indexA = rundown.findIndex((event) => event.id === fromId); - const eventA = rundown.at(indexA); + const fromEvent = rundown.entries[fromId]; + const toEvent = rundown.entries[toId]; - const indexB = rundown.findIndex((event) => event.id === toId); - const eventB = rundown.at(indexB); - - if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { + if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) { throw new Error('Swap only available for OntimeEvents'); } - const { newA, newB } = swapEventData(eventA, eventB); - const newRundown = [...rundown]; + const [newFrom, newTo] = swapEventData(fromEvent, toEvent); - newRundown[indexA] = newA; - (newRundown[indexA] as OntimeEvent).revision += 1; - newRundown[indexB] = newB; - (newRundown[indexB] as OntimeEvent).revision += 1; + rundown.entries[fromId] = newFrom; + rundown.entries[toId] = newTo; setIsStale(); - return { newRundown, didMutate: true }; + return { newRundown: rundown, didMutate: true }; } /** @@ -468,7 +545,7 @@ function invalidateIfUsed(label: CustomFieldLabel) { // schedule a non priority cache update setImmediate(async () => { generate(); - await getDataProvider().setRundown(persistedRundown); + await getDataProvider().setRundown(currentRundownId, currentRundown); }); } diff --git a/apps/server/src/services/rundown-service/rundownCacheUtils.ts b/apps/server/src/services/rundown-service/rundownCacheUtils.ts index a1cbe7566..9eb6418cb 100644 --- a/apps/server/src/services/rundown-service/rundownCacheUtils.ts +++ b/apps/server/src/services/rundown-service/rundownCacheUtils.ts @@ -1,57 +1,35 @@ -import { - OntimeEvent, - isOntimeEvent, - OntimeRundown, - CustomFieldLabel, - CustomFields, - OntimeRundownEntry, - OntimeBaseEvent, -} from 'ontime-types'; +import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types'; import { dayInMs, getLinkedTimes } from 'ontime-utils'; /** - * Get linked event - */ -export function getLink(currentIndex: number, rundown: OntimeRundown): OntimeEvent | null { - // currently the link is the previous event - for (let i = currentIndex - 1; i >= 0; i--) { - const event = rundown[i]; - if (isOntimeEvent(event) && !event.skip) { - return event; - } - } - return null; -} - -/** - * Populates data from link, if necessary - * Mutates in place mutableEvent - * Mutates in place links + * Checks that link can be established (ie, events exist and are valid) + * and populates the time data from link + * With the current implementation, the links is always the previous playable event + * Mutates mutableEvent in place + * Mutates links in place */ export function handleLink( - currentIndex: number, - rundown: OntimeRundown, mutableEvent: OntimeEvent, + previousEvent: OntimeEvent | null, links: Record, ): void { if (!mutableEvent.linkStart) { return; } - const linkedEvent = getLink(currentIndex, rundown); - if (!linkedEvent) { - mutableEvent.linkStart = null; + /** + * If no previous event exist, we dont remove the link + * this means that the event will keep the behaviour in case a new event is added before + * However, we do add its ID to the links and prevent out-of-sync data + */ + if (!previousEvent) { + mutableEvent.linkStart = 'true'; return; } - // sometimes the client cannot set the previous event - if (mutableEvent.linkStart === 'true') { - mutableEvent.linkStart = linkedEvent.id; - } - - links[linkedEvent.id] = mutableEvent.id; - - const timePatch = getLinkedTimes(mutableEvent, linkedEvent); + const timePatch = getLinkedTimes(mutableEvent, previousEvent); + mutableEvent.linkStart = previousEvent.id; + links[previousEvent.id] = mutableEvent.id; // use object.assign to force mutation Object.assign(mutableEvent, timePatch); } @@ -125,7 +103,7 @@ enum RegenerateWhitelist { * given a patch, returns whether all keys are whitelisted * @param path */ -export function isDataStale(patch: Partial): boolean { +export function isDataStale(patch: Partial): boolean { return Object.keys(patch).some((key) => !(key in RegenerateWhitelist)); } @@ -157,7 +135,7 @@ export function hasChanges(existingEvent: T, newEvent */ export function calculateDayOffset( current: Pick, - previous?: Pick, + previous: Pick | null, ) { // if there is no previous there can't be a day offset if (!previous) { diff --git a/apps/server/src/services/rundown-service/rundownUtils.ts b/apps/server/src/services/rundown-service/rundownUtils.ts index b00668186..0d4ce8a87 100644 --- a/apps/server/src/services/rundown-service/rundownUtils.ts +++ b/apps/server/src/services/rundown-service/rundownUtils.ts @@ -1,61 +1,90 @@ -import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types'; -import { filterPlayable, filterTimedEvents } from 'ontime-utils'; +import { + OntimeEvent, + Rundown, + OntimeEntry, + PlayableEvent, + EntryId, + RundownEntries, + ProjectRundowns, +} from 'ontime-types'; import * as cache from './rundownCache.js'; /** - * returns the normalised rundown + * returns entire unfiltered rundown */ -export function getNormalisedRundown(): RundownCached { - return cache.get(); +export function getCurrentRundown(): Rundown { + return cache.getCurrentRundown(); } /** - * returns entire unfiltered rundown + * returns the the project rundown and the order arrays */ -export function getRundown(): OntimeRundown { - return cache.getPersistedRundown(); +export function getRundownData() { + return { + rundown: cache.getCurrentRundown(), + rundownOrder: cache.getEventOrder(), + }; } /** * returns all events of type OntimeEvent */ export function getTimedEvents(): OntimeEvent[] { - return filterTimedEvents(getRundown()); + const { entries } = cache.get(); + const { timedEventsOrder } = cache.getEventOrder(); + return makeFlatRundownFromOrder(timedEventsOrder, entries); } /** - * returns all events that can be loaded + * Utility flattens a normalised rundown */ -export function getPlayableEvents(): PlayableEvent[] { - return filterPlayable(getRundown()); +function makeFlatRundownFromOrder(order: EntryId[], events: RundownEntries): T[] { + return order.map((id) => events[id] as T); } /** * returns an event given its index after filtering for OntimeEvents */ export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { - const timedEvents = getTimedEvents(); - return timedEvents.at(eventIndex); + const { timedEventsOrder } = cache.getEventOrder(); + const eventId = timedEventsOrder[eventIndex]; + + if (!eventId) { + return undefined; + } + const { entries } = getCurrentRundown(); + return entries[eventId] as OntimeEvent | undefined; } /** * returns first event that matches a given ID */ -export function getEventWithId(eventId: string): OntimeRundownEntry | undefined { - const rundown = getRundown(); - return rundown.find((event) => event.id === eventId); +export function getEventWithId(eventId: string): OntimeEntry | undefined { + const { entries } = getCurrentRundown(); + return entries[eventId]; +} + +/** + * Utility returns the first playable event in rundown + */ +export function getFirstPlayable(playableOrder: EntryId[]): PlayableEvent | undefined { + const firstEventId = playableOrder.at(0); + if (!firstEventId) return; + return getEventWithId(firstEventId) as PlayableEvent | undefined; } /** * returns first event that matches a given cue */ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined { - const playableEvents = getPlayableEvents(); + const { playableEventsOrder } = cache.getEventOrder(); + const lowerCaseCue = targetCue.toLowerCase(); - for (let i = currentEventIndex; i < playableEvents.length; i++) { - const event = playableEvents.at(i); + for (let i = currentEventIndex; i < playableEventsOrder.length; i++) { + const eventId = playableEventsOrder[i]; + const event = getEventWithId(eventId) as PlayableEvent | undefined; if (event?.cue.toLowerCase() === lowerCaseCue) { return event; } @@ -65,39 +94,74 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O /** * finds the previous event */ -export function findPrevious(currentEventId?: string): OntimeEvent | null { - const playableEvents = getPlayableEvents(); - if (!playableEvents || !playableEvents.length) { - return null; +export function findPrevious(currentEventId?: string): OntimeEvent | undefined { + const { playableEventsOrder } = cache.getEventOrder(); + + if (!playableEventsOrder.length) { + return; } // if there is no event running, go to first if (!currentEventId) { - return playableEvents.at(0) ?? null; + return getFirstPlayable(playableEventsOrder); } - const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); + const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId); const newIndex = Math.max(currentIndex - 1, 0); - const previousEvent = playableEvents.at(newIndex) ?? null; - return previousEvent; + const previousEventId = playableEventsOrder.at(newIndex); + + if (!previousEventId) { + return getFirstPlayable(playableEventsOrder); + } + + return getEventWithId(previousEventId) as PlayableEvent | undefined; } /** * finds the next event */ -export function findNext(currentEventId?: string): PlayableEvent | null { - const playableEvents = getPlayableEvents(); - if (!playableEvents.length) { - return null; +export function findNext(currentEventId?: string): PlayableEvent | undefined { + const { playableEventsOrder } = cache.getEventOrder(); + + if (!playableEventsOrder.length) { + return; } // if there is no event running, go to first if (!currentEventId) { - return playableEvents.at(0) ?? null; + return getFirstPlayable(playableEventsOrder); } - const currentIndex = playableEvents.findIndex((event) => event.id === currentEventId); - const newIndex = currentIndex + 1; - const nextEvent = playableEvents.at(newIndex); - return nextEvent ?? null; + const currentIndex = playableEventsOrder.findIndex((eventId) => eventId === currentEventId); + const newIndex = Math.min(currentIndex + 1, playableEventsOrder.length - 1); + const nextEventId = playableEventsOrder.at(newIndex); + + if (!nextEventId) { + return getFirstPlayable(playableEventsOrder); + } + + return getEventWithId(nextEventId) as PlayableEvent | undefined; +} + +export function filterTimedEvents(rundown: Rundown, timedEventOrder: EntryId[]): OntimeEvent[] { + return timedEventOrder.map((id) => rundown.entries[id] as OntimeEvent); +} + +/** + * Gets the first rundown in the project + * We ensure that the projects always have a rundown + */ +export function getFirstRundown(rundowns: ProjectRundowns): Rundown { + const firstKey = Object.keys(rundowns)[0]; + return rundowns[firstKey]; +} + +/** + * Returns a rundown given its ID + */ +export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string): Rundown { + if (!rundowns[rundownId]) { + throw new Error(`Rundown with ID ${rundownId} not found`); + } + return rundowns[rundownId]; } diff --git a/apps/server/src/services/runtime-service/RuntimeService.ts b/apps/server/src/services/runtime-service/RuntimeService.ts index 46232b82a..071e0b976 100644 --- a/apps/server/src/services/runtime-service/RuntimeService.ts +++ b/apps/server/src/services/runtime-service/RuntimeService.ts @@ -31,13 +31,15 @@ import { getEventAtIndex, getNextEventWithCue, getEventWithId, - getRundown, + getCurrentRundown, getTimedEvents, + getRundownData, } from '../rundown-service/rundownUtils.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; import { skippedOutOfEvent } from '../timerUtils.js'; import { triggerAutomations } from '../../api-data/automation/automation.service.js'; +import { getEventOrder } from '../rundown-service/rundownCache.js'; type RuntimeStateEventKeys = keyof Pick; @@ -270,8 +272,9 @@ class RuntimeService { if (onlyChangedNow) { runtimeState.updateLoaded(eventNow); } else { - const rundown = getRundown(); - runtimeState.updateAll(rundown); + const rundown = getCurrentRundown(); + const { timedEventsOrder } = getEventOrder(); + runtimeState.updateAll(rundown, timedEventsOrder); } return; } @@ -298,8 +301,8 @@ class RuntimeService { } const previousState = runtimeState.getState(); - const rundown = getRundown(); - const success = runtimeState.load(event, rundown, initialData); + const { rundown, rundownOrder } = getRundownData(); + const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData); if (success) { logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); @@ -583,9 +586,11 @@ class RuntimeService { * Handles special case to call roll on a loaded event which we do not want to discard */ private rollLoaded(offset?: number) { - const rundown = getRundown(); + const rundown = getCurrentRundown(); + const { timedEventsOrder } = getEventOrder(); + try { - runtimeState.roll(rundown, offset); + runtimeState.roll(rundown, timedEventsOrder, offset); } catch (error) { logger.error(LogOrigin.Server, `Roll: ${error}`); } @@ -605,8 +610,8 @@ class RuntimeService { } try { - const rundown = getRundown(); - const result = runtimeState.roll(rundown); + const { rundown, rundownOrder } = getRundownData(); + const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder); const newState = runtimeState.getState(); if (result.eventId !== previousState.eventNow?.id) { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); @@ -657,8 +662,8 @@ class RuntimeService { return; } - const rundown = getRundown(); - runtimeState.resume(restorePoint, event, rundown); + const { rundown, rundownOrder } = getRundownData(); + runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder); logger.info(LogOrigin.Playback, 'Resuming playback'); } diff --git a/apps/server/src/services/sheet-service/SheetService.ts b/apps/server/src/services/sheet-service/SheetService.ts index a30f148fc..ef098f04d 100644 --- a/apps/server/src/services/sheet-service/SheetService.ts +++ b/apps/server/src/services/sheet-service/SheetService.ts @@ -4,7 +4,7 @@ * @link https://developers.google.com/identity/protocols/oauth2/limited-input-device */ -import { AuthenticationStatus, CustomFields, LogOrigin, MaybeString, OntimeRundown } from 'ontime-types'; +import { AuthenticationStatus, CustomFields, DatabaseModel, LogOrigin, MaybeString, Rundown } from 'ontime-types'; import { ImportMap, getErrorMessage } from 'ontime-utils'; import { sheets, type sheets_v4 } from '@googleapis/sheets'; @@ -13,8 +13,8 @@ import got from 'got'; import { parseExcel } from '../../utils/parser.js'; import { logger } from '../../classes/Logger.js'; -import { parseRundown } from '../../utils/parserFunctions.js'; -import { getRundown } from '../rundown-service/rundownUtils.js'; +import { parseRundowns } from '../../utils/parserFunctions.js'; +import { getCurrentRundown, getRundownOrThrow } from '../rundown-service/rundownUtils.js'; import { getCustomFields } from '../rundown-service/rundownCache.js'; import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.js'; @@ -292,8 +292,8 @@ export async function upload(sheetId: string, options: ImportMap) { throw new Error(`Sheet read failed: ${readResponse.statusText}`); } - const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), options); - const rundown = getRundown(); + const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options); + const rundown = getCurrentRundown(); const titleRow = Object.values(rundownMetadata)[0]['row']; const updateRundown = Array(); @@ -322,16 +322,17 @@ export async function upload(sheetId: string, options: ImportMap) { range: { dimension: 'ROWS', startIndex: titleRow + 1, - endIndex: titleRow + rundown.length, + endIndex: titleRow + rundown.order.length, sheetId: worksheetId, }, }, }); // update the corresponding row with event data - rundown.forEach((entry, index) => - updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)), - ); + rundown.order.forEach((entryId, index) => { + const entry = rundown.entries[entryId]; + return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)); + }); const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ spreadsheetId: sheetId, @@ -353,7 +354,7 @@ export async function download( sheetId: string, options: ImportMap, ): Promise<{ - rundown: OntimeRundown; + rundown: Rundown; customFields: CustomFields; }> { const { range } = await verifyWorksheet(sheetId, options.worksheet); @@ -369,10 +370,19 @@ export async function download( throw new Error(`Sheet read failed: ${googleResponse.statusText}`); } - const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), options); - const { customFields, rundown } = parseRundown(dataFromSheet); - if (rundown.length < 1) { + const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options); + + const rundownId = dataFromSheet.rundown.id; + const dataModel: Pick = { + rundowns: { + [rundownId]: dataFromSheet.rundown, + }, + customFields: dataFromSheet.customFields, + }; + const { customFields, rundowns } = parseRundowns(dataModel); + const rundown = getRundownOrThrow(rundowns, rundownId); + if (rundown.order.length < 1) { throw new Error('Sheet: Could not find data to import in the worksheet'); } - return { rundown, customFields }; + return { rundown: rundowns[rundownId], customFields }; } diff --git a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts index 4f7861ecf..fbb7e2699 100644 --- a/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts +++ b/apps/server/src/services/sheet-service/__tests__/sheetUtils.test.ts @@ -39,6 +39,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, + currentBlock: null, revision: 0, id: '1358', timeWarning: 0, @@ -84,6 +85,7 @@ describe('cellRequestFromEvent()', () => { isPublic: false, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -134,6 +136,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -186,6 +189,7 @@ describe('cellRequestFromEvent()', () => { delay: 0, gap: 0, dayOffset: 0, + currentBlock: null, revision: 0, id: '1358', timeWarning: 0, @@ -218,6 +222,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, @@ -254,6 +259,7 @@ describe('cellRequestFromEvent()', () => { isPublic: true, skip: false, colour: 'red', + currentBlock: null, revision: 0, delay: 0, gap: 0, diff --git a/apps/server/src/services/sheet-service/sheetUtils.ts b/apps/server/src/services/sheet-service/sheetUtils.ts index 8e7742f74..e796318a5 100644 --- a/apps/server/src/services/sheet-service/sheetUtils.ts +++ b/apps/server/src/services/sheet-service/sheetUtils.ts @@ -1,4 +1,4 @@ -import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; +import { isOntimeBlock, isOntimeEvent, OntimeEvent, OntimeEntry } from 'ontime-types'; import { millisToString } from 'ontime-utils'; import type { sheets_v4 } from '@googleapis/sheets'; @@ -74,14 +74,14 @@ export function getA1Notation(row: number, column: number): string { /** * @description - creates updateCells request from ontime event - * @param {OntimeRundownEntry} event + * @param {OntimeEntry} event * @param {number} index - index of the event * @param {number} worksheetId * @param {object} metadata - object with all the cell positions of the title of each attribute * @returns {sheets_v4.Schema} - list of update requests */ export function cellRequestFromEvent( - event: OntimeRundownEntry, + event: OntimeEntry, index: number, worksheetId: number, metadata: object, @@ -125,7 +125,7 @@ export function cellRequestFromEvent( }; } -function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeRundownEntry) { +function getCellData(key: keyof OntimeEvent | 'blank', event: OntimeEntry) { if (isOntimeEvent(event)) { if (key === 'blank') { return {}; diff --git a/apps/server/src/stores/__tests__/runtimeState.test.ts b/apps/server/src/stores/__tests__/runtimeState.test.ts index b385e6588..1c056cd53 100644 --- a/apps/server/src/stores/__tests__/runtimeState.test.ts +++ b/apps/server/src/stores/__tests__/runtimeState.test.ts @@ -1,5 +1,11 @@ -import { OntimeRundown, PlayableEvent, Playback, SupportedEvent, TimerPhase } from 'ontime-types'; -import { deepmerge } from 'ontime-utils'; +import { PlayableEvent, Playback, TimerPhase } from 'ontime-types'; + +import { initRundown } from '../../services/rundown-service/RundownService.js'; +import { + makeOntimeBlock, + makeOntimeEvent, + makeRundown, +} from '../../services/rundown-service/__mocks__/rundown.mocks.js'; import { type RuntimeState, @@ -13,7 +19,6 @@ import { start, stop, } from '../runtimeState.js'; -import { initRundown } from '../../services/rundown-service/RundownService.js'; const mockEvent = { type: 'event', @@ -23,6 +28,7 @@ const mockEvent = { timeEnd: 1000, duration: 1000, skip: false, + currentBlock: null, } as PlayableEvent; const mockState = { @@ -51,11 +57,6 @@ const mockState = { }, } as RuntimeState; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const makeMockState = (patch: RuntimeState): RuntimeState => { - return deepmerge(mockState, patch); -}; - beforeAll(() => { vi.mock('../../classes/data-provider/DataProvider.js', () => { return { @@ -70,23 +71,14 @@ beforeAll(() => { }); describe('mutation on runtimeState', () => { - beforeEach(() => { + beforeEach(async () => { clearState(); - vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => { const actual = (await importOriginal()) as object; return { ...actual, - getPlayableEvents: vi.fn().mockReturnValue([ - { - id: 'mock', - cue: 'mock', - timeStart: 0, - timeEnd: 1000, - duration: 1000, - }, - ]), + initRunddown: vi.fn().mockReturnValue(undefined), }; }); }); @@ -97,15 +89,18 @@ describe('mutation on runtimeState', () => { describe('playback operations', async () => { it('refuses if nothing is loaded', () => { + initRundown(makeRundown({}), {}); let success = start(mockState); expect(success).toBe(false); success = pause(); expect(success).toBe(false); }); + test('normal playback cycle', () => { // 1. Load event - load(mockEvent, [mockEvent]); + const mockRundown = makeRundown({ entries: { [mockEvent.id]: mockEvent }, order: [mockEvent.id] }); + load(mockEvent, mockRundown, mockRundown.order); let newState = getState(); expect(newState.eventNow?.id).toBe(mockEvent.id); expect(newState.timer.playback).toBe(Playback.Armed); @@ -170,17 +165,21 @@ describe('mutation on runtimeState', () => { }); // do this before the test so that it is applied - const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 }; - const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 }; + const entries = { + event1: { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000, currentBlock: null }, + event2: { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500, currentBlock: null }, + }; + const rundown = makeRundown({ entries, order: ['event1', 'event2'] }); + // force update vi.useFakeTimers(); - await initRundown([event1, event2], {}); + await initRundown(rundown, {}); vi.runAllTimers(); vi.useRealTimers(); test('runtime offset', async () => { // 1. Load event - load(event1, [event1, event2]); + load(entries.event1, rundown, rundown.order); let newState = getState(); expect(newState.runtime.actualStart).toBeNull(); expect(newState.runtime.plannedStart).toBe(0); @@ -197,11 +196,11 @@ describe('mutation on runtimeState', () => { } expect(newState.runtime.actualStart).toBe(newState.clock); - expect(newState.runtime.offset).toBe(event1.timeStart - newState.clock); - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.offset).toBe(entries.event1.timeStart - newState.clock); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); // 3. Next event - load(event2, [event1, event2]); + load(entries.event2, rundown, rundown.order); start(); newState = getState(); @@ -214,10 +213,10 @@ describe('mutation on runtimeState', () => { const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart); expect(forgivingActualStart).toBeLessThanOrEqual(1); // we are over-under, the difference between the schedule and the actual start - const delayBefore = event2.timeStart - newState.clock; + const delayBefore = entries.event2.timeStart - newState.clock; expect(newState.runtime.offset).toBe(delayBefore); // finish is the difference between the runtime and the schedule - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); expect(newState.currentBlock.block).toBeNull(); // 4. Add time @@ -228,7 +227,7 @@ describe('mutation on runtimeState', () => { } expect(newState.runtime.offset).toBe(delayBefore - 10); - expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); + expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset); // 5. Stop event stop(); @@ -237,8 +236,6 @@ describe('mutation on runtimeState', () => { expect(newState.runtime.offset).toBe(0); expect(newState.runtime.expectedEnd).toBeNull(); }); - - test.todo('runtime offset on timers in overtime', () => {}); }); }); @@ -253,14 +250,17 @@ describe('roll mode', () => { }); describe('normal roll', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); test('pending event', () => { - const { eventId, didStart } = roll(rundown); + const { eventId, didStart } = roll(rundown, rundown.order); const state = getState(); expect(eventId).toBe('1'); @@ -271,29 +271,32 @@ describe('roll mode', () => { test('roll events', () => { vi.setSystemTime('jan 1 00:00:01'); - let result = roll(rundown); + let result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '1', didStart: true }); vi.setSystemTime('jan 1 00:00:02'); - result = roll(rundown); + result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '2', didStart: true }); vi.setSystemTime('jan 1 00:00:03:500'); - result = roll(rundown); + result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '3', didStart: true }); }); }); describe('roll takover', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); test('from load', () => { - load(rundown[2], rundown); - const result = roll(rundown); + load(rundown.entries[3] as PlayableEvent, rundown, rundown.order); + const result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '3', didStart: false }); const state = getState(); expect(state.timer.phase).toBe(TimerPhase.Pending); @@ -301,9 +304,9 @@ describe('roll mode', () => { }); test('from play', () => { - load(rundown[0], rundown); + load(rundown.entries[1] as PlayableEvent, rundown, rundown.order); start(); - const result = roll(rundown); + const result = roll(rundown, rundown.order); expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(getState().runtime.offset).toBe(1000); }); @@ -311,153 +314,167 @@ describe('roll mode', () => { describe('roll continue with offset', () => { test('no gaps', () => { - const rundown = [ - { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, - { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, - { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, - ] as PlayableEvent[]; + const rundown = makeRundown({ + entries: { + 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, + 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, + 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, + }, + order: ['1', '2', '3'], + }); - load(rundown[0], rundown); + load(rundown.entries[1] as PlayableEvent, rundown, rundown.order); start(); - let result = roll(rundown, getState().runtime.offset); + let result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(getState().runtime.offset).toBe(1000); vi.setSystemTime('jan 1 00:00:01'); - result = roll(rundown, getState().runtime.offset); + result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '2', didStart: true }); expect(getState().runtime.offset).toBe(1000); vi.setSystemTime('jan 1 00:00:02'); - result = roll(rundown, getState().runtime.offset); + result = roll(rundown, rundown.order, getState().runtime.offset); expect(result).toStrictEqual({ eventId: '3', didStart: true }); expect(getState().runtime.offset).toBe(1000); }); - - test.todo('with gaps', () => { - //this is a bit involved as it also depends somewhat on the RintimeService - }); }); }); describe('loadBlock', () => { test('from no-block to a block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: [] }), + 3: makeOntimeEvent({ id: '3', currentBlock: null }), + }, + order: ['0', '1', '2', '3'], + }); const state = { currentBlock: { block: null, startedAt: 123, }, - eventNow: rundown[2], + eventNow: rundown.entries[11], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[1], startedAt: null }, - eventNow: rundown[2], + currentBlock: { block: rundown.entries[1], startedAt: null }, + eventNow: rundown.entries[11], }); }); test('from block to a different block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: ['22'] }), + 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + }, + order: ['0', '1', '2'], + }); const state = { currentBlock: { - block: rundown[1], + block: rundown.entries[1], startedAt: 123, }, - eventNow: rundown[4], + eventNow: rundown.entries[22], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[3], startedAt: null }, - eventNow: rundown[4], + currentBlock: { block: rundown.entries[2], startedAt: null }, + eventNow: rundown.entries[22], }); }); test('from block to a no-block will clear startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Block }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeBlock({ id: '1', events: ['11'] }), + 11: makeOntimeEvent({ id: '11', currentBlock: '1' }), + 2: makeOntimeBlock({ id: '2', events: ['22'] }), + 22: makeOntimeEvent({ id: '22', currentBlock: '2' }), + }, + order: ['0', '1', '2'], + }); const state = { currentBlock: { - block: rundown[1], + block: rundown.entries[1], startedAt: 123, }, - eventNow: rundown[0], + eventNow: rundown.entries[0], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ currentBlock: { block: null, startedAt: null }, - eventNow: rundown[0], + eventNow: rundown.entries[0], }); }); test('from block to same block will keep startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Block }, - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeBlock({ id: '0', events: ['1', '2'] }), + 1: makeOntimeEvent({ id: '1', currentBlock: '0' }), + 2: makeOntimeEvent({ id: '2', currentBlock: '0' }), + }, + order: ['0'], + }); const state = { currentBlock: { - block: rundown[0], + block: rundown.entries[0], startedAt: 123, }, - eventNow: rundown[2], + eventNow: rundown.entries[2], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ - currentBlock: { block: rundown[0], startedAt: 123 }, - eventNow: rundown[2], + currentBlock: { block: rundown.entries[0], startedAt: 123 }, + eventNow: rundown.entries[2], }); }); test('from no-block to no-block will keep startedAt', () => { - const rundown = [ - { id: '0', type: SupportedEvent.Event }, - { id: '1', type: SupportedEvent.Event }, - ] as OntimeRundown; + const rundown = makeRundown({ + entries: { + 0: makeOntimeEvent({ id: '0', currentBlock: null }), + 1: makeOntimeEvent({ id: '1', currentBlock: null }), + }, + order: ['0', '1'], + }); const state = { currentBlock: { block: null, startedAt: 123, }, - eventNow: rundown[0], + eventNow: rundown.entries[0], } as RuntimeState; loadBlock(rundown, state); expect(state).toMatchObject({ currentBlock: { block: null, startedAt: 123 }, - eventNow: rundown[0], + eventNow: rundown.entries[0], }); }); }); diff --git a/apps/server/src/stores/runtimeState.ts b/apps/server/src/stores/runtimeState.ts index 1eaacd8d8..a095a71a9 100644 --- a/apps/server/src/stores/runtimeState.ts +++ b/apps/server/src/stores/runtimeState.ts @@ -1,26 +1,20 @@ import { CurrentBlockState, + EntryId, isPlayableEvent, MaybeNumber, MaybeString, - OffsetMode, + OntimeBlock, OntimeEvent, - OntimeRundown, PlayableEvent, Playback, + Rundown, Runtime, runtimeStorePlaceholder, TimerPhase, TimerState, } from 'ontime-types'; -import { - calculateDuration, - checkIsNow, - dayInMs, - filterTimedEvents, - getPreviousBlock, - isPlaybackActive, -} from 'ontime-utils'; +import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils'; import { timeNow } from '../utils/time.js'; import type { RestorePoint } from '../services/RestoreService.js'; @@ -33,6 +27,7 @@ import { } from '../services/timerUtils.js'; import { timerConfig } from '../config/config.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; +import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js'; export type RuntimeState = { clock: number; // realtime clock @@ -183,19 +178,23 @@ export function updateRundownData(rundownData: RundownData) { */ export function load( event: PlayableEvent, - rundown: OntimeRundown, + rundown: Rundown, + timedEventsOrder: EntryId[], initialData?: Partial, ): boolean { clearEventData(); - // filter rundown - const timedEvents = filterTimedEvents(rundown); - const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id); - - if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) { + if (timedEventsOrder.length === 0 || !isPlayableEvent(event)) { return false; } + // filter rundown + const eventIndex = timedEventsOrder.findIndex((timedEventId) => timedEventId === event.id); + if (eventIndex === -1) { + return false; + } + + const timedEvents = filterTimedEvents(rundown, timedEventsOrder); // load events in memory along with their data loadNow(timedEvents, eventIndex); loadNext(timedEvents, eventIndex); @@ -312,8 +311,8 @@ export function loadNext( /** * Resume from restore point */ -export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) { - load(event, rundown, restorePoint); +export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: Rundown, timedEventOrder: EntryId[]) { + load(event, rundown, timedEventOrder, restorePoint); } /** @@ -373,8 +372,8 @@ export function updateLoaded(event?: PlayableEvent): string | undefined { /** * Used in situations when we want to hot-reload all events without interrupting timer */ -export function updateAll(rundown: OntimeRundown) { - const timedEvents = filterTimedEvents(rundown); +export function updateAll(rundown: Rundown, timedEventsOrder: EntryId[]) { + const timedEvents = filterTimedEvents(rundown, timedEventsOrder); loadNow(timedEvents); loadNext(timedEvents); updateLoaded(runtimeState.eventNow ?? undefined); @@ -388,6 +387,7 @@ export function start(state: RuntimeState = runtimeState): boolean { if (state.timer.playback === Playback.Play) { return false; } + state.clock = timeNow(); state.timer.secondaryTimer = null; @@ -576,7 +576,11 @@ export function update(): UpdateResult { } } -export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString; didStart: boolean } { +export function roll( + rundown: Rundown, + timedEventOrder: EntryId[], + offset = 0, +): { eventId: MaybeString; didStart: boolean } { // 1. if an event is running, we simply take over the playback if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { runtimeState.timer.playback = Playback.Roll; @@ -633,7 +637,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString } // 3. if there is no event running, we need to find the next event - const timedEvents = filterTimedEvents(rundown); + const timedEvents = filterTimedEvents(rundown, timedEventOrder); if (timedEvents.length === 0) { throw new Error('No playable events found'); } @@ -709,9 +713,8 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString /** * handle block loading, not for use outside of runtimeState - * @param rundown */ -export function loadBlock(rundown: OntimeRundown, state = runtimeState) { +export function loadBlock(rundown: Rundown, state = runtimeState) { if (state.eventNow === null) { // we need a loaded event to have a block state.currentBlock.block = null; @@ -719,17 +722,19 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) { return; } - const newCurrentBlock = getPreviousBlock(rundown, state.eventNow.id); + const currentBlockId = state.eventNow.currentBlock; // update time only if the block has changed - if (state.currentBlock.block?.id !== newCurrentBlock?.id) { + if (state.currentBlock.block?.id != currentBlockId) { state.currentBlock.startedAt = null; } // update the block anyway - state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock }; -} + if (currentBlockId === null) { + state.currentBlock.block = null; + return; + } -export function setOffsetMode(mode: OffsetMode) { - runtimeState.runtime.offsetMode = mode; + const currentBlock = rundown.entries[currentBlockId]; + state.currentBlock.block = currentBlock as OntimeBlock; } diff --git a/apps/server/src/utils/__tests__/parser.mock-data.ts b/apps/server/src/utils/__tests__/parser.mock-data.ts new file mode 100644 index 000000000..5de92840a --- /dev/null +++ b/apps/server/src/utils/__tests__/parser.mock-data.ts @@ -0,0 +1,59 @@ +export const dataFromExcelTemplate = [ + ['Ontime ┬À Schedule Template'], + [], + [ + 'id', + 'Time Start', + 'Time End', + 'Title', + 'End Action', + 'Timer type', + 'Count to end', + 'Public', + 'Skip', + 'Notes', + 't0', + 'Test1', + 'test2', + 'test3', + 'Colour', + 'cue', + ], + [ + 'event-a', // <-- eventId + '07:00:00', // <-- timeStart + '08:00:10', // <-- timeEnd + 'Guest Welcome', // <-- title + '', // <-- endAction + '', // <-- timerType + 'x', // <-- count to end + 'x', // <-- public + '', // <-- skip + 'Ballyhoo', // <-- notes + 'a0', // <-- t0 + 'a1', // <-- test1 + 'a2', // <-- test2 + 'a3', // <-- test3 + 'red', // <-- colour + 101, // <-- cue + ], + [ + 'event-b', // <-- eventId + '08:00:00', // <-- timeStart + '08:30:00', // <-- timeEnd + 'A song from the hearth', // <-- title + 'load-next', // <-- endAction + 'clock', // timerType + 'x', // <-- count to end + '', // <-- public + 'x', // <-- skip + 'Rainbow chase', // <-- notes + 'b0', // <-- t0 + '', // <-- test1 + '', // <-- test2 + '', // <-- test3 + '#F00', // <-- colour + 102, // <-- cue + ], + [], +]; diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index 3f34bb90d..8b6d77973 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -1,32 +1,17 @@ /* eslint-disable no-console -- we are mocking the console */ import { assertType, vi } from 'vitest'; -import { - CustomFields, - DatabaseModel, - EndAction, - OntimeEvent, - OntimeRundown, - ProjectData, - Settings, - SupportedEvent, - TimerType, - TimeStrategy, - ViewSettings, -} from 'ontime-types'; +import { CustomFields, DatabaseModel, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types'; +import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; import { dbModel } from '../../models/dataModel.js'; +import { demoDb } from '../../models/demoProject.js'; import { createEvent, getCustomFieldData, parseExcel, parseDatabaseModel } from '../parser.js'; import { makeString } from '../parserUtils.js'; -import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js'; -import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils'; -import * as cache from '../../services/rundown-service/rundownCache.js'; +import { parseUrlPresets, parseViewSettings } from '../parserFunctions.js'; -const requiredSettings = { - app: 'ontime', - version: 'any', -}; +import { dataFromExcelTemplate } from './parser.mock-data.js'; // mock data provider beforeAll(() => { @@ -42,302 +27,24 @@ beforeAll(() => { }); }); -describe('test json parser with valid def', () => { - const testData: Partial = { - rundown: [ - { - id: '4b31', - cue: 'Guest Welcoming', - type: SupportedEvent.Event, - title: 'Guest Welcoming', - note: '', - endAction: EndAction.PlayNext, - timerType: TimerType.Clock, - timeStart: 31500000, - timeEnd: 32400000, - duration: 32400000 - 31500000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: 'f24d', - cue: 'Good Morning', - type: SupportedEvent.Event, - title: 'Good Morning', - note: '', - endAction: EndAction.PlayNext, - timerType: TimerType.CountUp, - timeStart: 32400000, - timeEnd: 36000000, - duration: 36000000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: true, - skip: true, - colour: 'red', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: 'bbc5', - cue: 'Stage 2 setup', - type: SupportedEvent.Event, - title: 'Stage 2 setup', - note: '', - endAction: 'wrong action' as EndAction, // testing - timerType: TimerType.Clock, - timeStart: 32400000, - timeEnd: 37200000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - // testing incomplete dataset - id: '5b3e', - cue: 'Working Procedures', - type: SupportedEvent.Event, - title: 'Working Procedures', - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 37200000, - timeEnd: 39000000, - duration: 39000000 - 37200000, - isPublic: true, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - } as OntimeEvent, - { - id: '8e2c', - cue: 'Lunch', - title: 'Lunch', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 39600000, - timeEnd: 45000000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: false, - skip: false, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - id: '08e9', - cue: 'A day being carlos', - title: 'A day being carlos', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 46800000, - timeEnd: 50400000, - duration: 37200000 - 32400000, - timeStrategy: TimeStrategy.LockEnd, - linkStart: null, - isPublic: true, - skip: true, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - custom: {}, - }, - { - // testing incomplete dataset - id: 'e25a', - cue: 'Hamburgers and Cheese', - title: 'Hamburgers and Cheese', - type: SupportedEvent.Event, - note: '', - endAction: EndAction.None, - timerType: TimerType.Clock, - timeStart: 54000000, - timeEnd: 57600000, - duration: 37200000 - 32400000, - isPublic: true, - colour: '', - revision: 0, - timeWarning: 0, - timeDanger: 0, - } as OntimeEvent, - ] as OntimeRundown, - project: { - title: 'This is a test definition', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - } as ProjectData, - settings: { - app: 'ontime', - version: '2.0.0', - timeFormat: '24', - } as Settings, - viewSettings: {} as ViewSettings, - }; +describe('test parseDatabaseModel() with demo project (valid)', () => { + const filteredDemoProject = structuredClone(demoDb); + const { data } = parseDatabaseModel(filteredDemoProject); - const { data } = parseDatabaseModel(testData); + delete filteredDemoProject.settings.version; + delete data.settings.version; - it('has 7 events', () => { - const length = data.rundown.length; - expect(length).toBe(7); + it('has 16 events', () => { + expect(data.rundowns.demo.order.length).toBe(16); + expect(Object.keys(data.rundowns.demo.entries).length).toBe(16); }); - it('first event is as a match', () => { - const first = data.rundown[0]; - const expected = { - title: 'Guest Welcoming', - type: 'event', - id: '4b31', - }; - expect(first).toMatchObject(expected); - }); - - it('second event is as a match', () => { - const second = data.rundown[1]; - const expected = { - title: 'Good Morning', - type: 'event', - id: 'f24d', - }; - expect(second).toMatchObject(expected); - }); - it('third event end action is set as the default value', () => { - const third = data.rundown[2]; - expect((third as OntimeEvent).endAction).toStrictEqual(EndAction.None); - }); - it('fourth event timer type is set as the default value', () => { - const fourth = data.rundown[3]; - expect((fourth as OntimeEvent).timerType).toStrictEqual(TimerType.Clock); - }); - - it('loaded event settings', () => { - const eventTitle = data.project.title; - expect(eventTitle).toBe('This is a test definition'); - }); - - it('endMessage to exist but be empty', () => { - const endMessage = data.viewSettings.endMessage; - expect(endMessage).toBeDefined(); - expect(endMessage).toBe(''); - }); - - it('settings are for right app and version', () => { - const settings = data.settings; - expect(settings.app).toBe('ontime'); - expect(settings.version).toEqual(expect.any(String)); + it('is the same as the demo project since all data is valid', () => { + expect(data).toMatchObject(filteredDemoProject); }); }); -describe('test parser edge cases', () => { - it('stringifies necessary values', () => { - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - cue: 101, - type: 'event', - }, - { - cue: 101.1, - type: 'event', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(typeof (data.rundown[0] as OntimeEvent).cue).toBe('string'); - expect(typeof (data.rundown[1] as OntimeEvent).cue).toBe('string'); - }); - - it('generates missing ids', () => { - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event', - type: 'event', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(data.rundown[0].id).toBeDefined(); - }); - - it('detects duplicate Ids', () => { - console.log = vi.fn(); - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event 1', - type: 'event', - id: '1', - }, - { - title: 'Test Event 2', - type: 'event', - id: '1', - }, - ], - }; - - //@ts-expect-error -- we know this is wrong, testing imports outside domain - const { data, errors } = parseDatabaseModel(testData); - expect(data.rundown.length).toBe(1); - expect(errors.length).toBe(5); - }); - - it('handles incomplete datasets', () => { - console.log = vi.fn(); - const testData = { - settings: { ...requiredSettings }, - rundown: [ - { - title: 'Test Event 1', - id: '1', - }, - { - title: 'Test Event 2', - id: '1', - }, - ], - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(testData); - expect(data.rundown.length).toBe(0); - }); - +describe('test parseDatabaseModel() edge cases', () => { it('skips unknown app and version settings', () => { console.log = vi.fn(); const testData = { @@ -349,108 +56,6 @@ describe('test parser edge cases', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain expect(() => parseDatabaseModel(testData)).toThrow(); }); -}); - -describe('test corrupt data', () => { - it('handles some empty events', () => { - const emptyEvents = { - rundown: [ - {}, - {}, - {}, - { - title: 'Test Event 1', - type: 'event', - id: '1', - }, - { - title: 'Test Event 2', - type: 'event', - id: '2', - }, - {}, - {}, - {}, - ], - project: { - title: 'All about Carlos demo event', - description: 'description', - publicUrl: 'www.carlosvalente.com', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - endMessage: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(emptyEvents); - expect(data.rundown.length).toBe(2); - }); - - it('handles all empty events', () => { - const emptyEvents = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - project: { - title: 'All about Carlos demo event', - description: 'description', - publicUrl: 'www.carlosvalente.com', - backstageUrl: 'www.carlosvalente.com', - publicInfo: 'WiFi: demoproject \nPassword: ontimeproject', - backstageInfo: 'WiFi: demobackstage\nPassword: ontimeproject', - endMessage: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(emptyEvents); - expect(data.rundown.length).toBe(0); - }); - - it('handles missing project data', () => { - const emptyProjectData = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - project: {}, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - lock: null, - timeFormat: '24', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data: parsedDef } = parseDatabaseModel(emptyProjectData); - expect(parsedDef.project).toStrictEqual(dbModel.project); - }); - - it('handles missing settings', () => { - const missingSettings = { - rundown: [{}, {}, {}, {}, {}, {}, {}, {}], - event: {}, - settings: { - app: 'ontime', - version: '2.0.0', - }, - }; - - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data } = parseDatabaseModel(missingSettings); - expect(data.settings).toStrictEqual(dbModel.settings); - }); it('fails with invalid JSON', () => { // @ts-expect-error -- we know this is wrong, testing imports outside domain @@ -608,125 +213,6 @@ describe('test views import', () => { }); }); -describe('test import of v2 datamodel', () => { - it('ignores deprecated fields and generates new ones', () => { - const v2ProjectFile = { - rundown: [ - { type: SupportedEvent.Block, title: 'block-title', id: 'block-id' }, - { type: SupportedEvent.Delay, duration: 0 }, - { type: SupportedEvent.Event, title: 'event-title', id: 'event-id' }, - ], - project: { - title: '', - description: '', - publicUrl: '', - publicInfo: '', - backstageUrl: '', - backstageInfo: '', - }, - settings: { - app: 'ontime', - version: '2.0.0', - serverPort: 4001, - editorKey: null, - operatorKey: null, - timeFormat: '24', - language: 'en', - }, - viewSettings: { - overrideStyles: false, - normalColor: '#ffffffcc', - warningColor: '#FFAB33', - dangerColor: '#ED3333', - endMessage: '', - }, - aliases: [], - userFields: { - user0: 'user0', - user1: 'user1', - user2: 'user2', - user3: 'user3', - user4: 'user4', - user5: 'user5', - user6: 'user6', - user7: 'user7', - user8: 'user8', - user9: 'user9', - }, - osc: { - portIn: 8888, - portOut: 9999, - targetIP: '127.0.0.1', - enabledIn: false, - enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - onWarning: [], - onDanger: [], - }, - }, - http: { - enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, - }, - }; - // @ts-expect-error -- we know this is wrong, testing imports outside domain - const { data: parsed, _errors } = parseDatabaseModel(v2ProjectFile); - expect(parsed.rundown.length).toBe(3); - expect(parsed.rundown[0]).toMatchObject({ type: SupportedEvent.Block }); - expect(parsed.rundown[0]).toEqual( - expect.objectContaining({ - id: expect.any(String), - title: expect.any(String), - }), - ); - expect(parsed.rundown[1]).toMatchObject({ type: SupportedEvent.Delay }); - expect(parsed.rundown[1]).toEqual( - expect.objectContaining({ - id: expect.any(String), - duration: expect.any(Number), - }), - ); - expect(parsed.rundown[2]).toMatchObject({ type: SupportedEvent.Event }); - expect(parsed.rundown[2]).toEqual( - expect.objectContaining({ - id: expect.any(String), - cue: expect.any(String), - title: expect.any(String), - note: expect.any(String), - endAction: expect.any(String), - timerType: expect.any(String), - linkStart: null, - timeStrategy: expect.any(String), - timeStart: expect.any(Number), - timeEnd: expect.any(Number), - duration: expect.any(Number), - isPublic: expect.any(Boolean), - skip: expect.any(Boolean), - colour: expect.any(String), - revision: expect.any(Number), - timeWarning: expect.any(Number), - timeDanger: expect.any(Number), - custom: expect.any(Object), - }), - ); - // @ts-expect-error -- checking if the field is removed - expect(parsed?.userFields).toBeUndefined(); - }); -}); - describe('makeString()', () => { it('converts variables to string', () => { const cases = [ @@ -885,162 +371,33 @@ describe('getCustomFieldData()', () => { describe('parseExcel()', () => { it('parses the example file', () => { - const testdata = [ - ['Ontime ┬À Schedule Template'], - [], - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Count to end', - 'Public', - 'Skip', - 'Notes', - 't0', - 'Test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '07:00:00', - '08:00:10', - 'Guest Welcome', - '', - '', - 'x', // <-- count to end - 'x', // <-- public - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '08:00:00', - '08:30:00', - 'A song from the hearth', - 'load-next', - 'clock', - 'x', // <-- count to end - '', // <-- public - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; - // partial import map with only custom fields const importMap = { custom: { user0: 't0', - User1: 'Test1', + user1: 'Test1', user2: 'test2', user3: 'test3', - user4: 'test4', - user5: 'test5', - user6: 'test6', - user7: 'test7', - user8: 'test8', - user9: 'test9', }, }; - // TODO: update tests once import is resolved - const expectedParsedRundown = [ - { - timeStart: 25200000, - timeEnd: 28810000, - title: 'Guest Welcome', - timerType: 'count-down', - endAction: 'none', - isPublic: true, - countToEnd: true, - skip: false, - note: 'Ballyhoo', - custom: { - user0: 'a0', - User1: 'a1', - user2: 'a2', - user3: 'a3', - user4: 'a4', - user5: 'a5', - user6: 'a6', - user7: 'a7', - user8: 'a8', - user9: 'a9', - }, - colour: 'red', - type: 'event', - cue: '101', - }, - { - timeStart: 28800000, - timeEnd: 30600000, - title: 'A song from the hearth', - timerType: 'clock', - countToEnd: true, - endAction: 'load-next', - isPublic: false, - skip: true, - note: 'Rainbow chase', - custom: { - user0: 'b0', - user5: 'b5', - }, - colour: '#F00', - type: 'event', - cue: '102', - }, - ]; - const existingCustomFields: CustomFields = { user0: { type: 'string', colour: 'red', label: 'user0' }, - User1: { type: 'string', colour: 'green', label: 'user1' }, + user1: { type: 'string', colour: 'green', label: 'user1' }, user2: { type: 'string', colour: 'blue', label: 'user2' }, }; - const { customFields, rundown } = parseExcel(testdata, existingCustomFields, importMap); - expect(customFields).toStrictEqual({ + const parsedData = parseExcel(dataFromExcelTemplate, existingCustomFields, 'testSheet', importMap); + expect(parsedData.customFields).toStrictEqual({ user0: { type: 'string', colour: 'red', label: 'user0', }, - User1: { + user1: { type: 'string', colour: 'green', - label: 'User1', + label: 'user1', }, user2: { type: 'string', @@ -1052,126 +409,12 @@ describe('parseExcel()', () => { colour: '', label: 'user3', }, - user4: { - type: 'string', - colour: '', - label: 'user4', - }, - user5: { - type: 'string', - colour: '', - label: 'user5', - }, - user6: { - type: 'string', - colour: '', - label: 'user6', - }, - user7: { - type: 'string', - colour: '', - label: 'user7', - }, - user8: { - type: 'string', - colour: '', - label: 'user8', - }, - user9: { - type: 'string', - colour: '', - label: 'user9', - }, }); - expect(rundown.length).toBe(2); - expect(rundown[0]).toMatchObject(expectedParsedRundown[0]); - expect(rundown[1]).toMatchObject(expectedParsedRundown[1]); - }); - - it('parses a file without custom fields', () => { - const testdata = [ - ['Ontime ┬À Schedule Template'], - [], - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '1899-12-30T07:00:00.000Z', - '1899-12-30T08:00:10.000Z', - 'Guest Welcome', - '', - '', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; - - // partial import map with only custom fields - const importMap = { - custom: { - niu1: 'niu1', - niu2: 'niu2', - }, - }; - - // TODO: update tests once import is resolved - const expectedParsedRundown = [ - { + expect(parsedData.rundown.order.length).toBe(2); + // TODO: why dont we parse the date in UTC? + expect(parsedData.rundown.entries).toMatchObject({ + 'event-a': { + id: 'event-a', //timeStart: 28800000, //timeEnd: 32410000, title: 'Guest Welcome', @@ -1180,12 +423,18 @@ describe('parseExcel()', () => { isPublic: true, skip: false, note: 'Ballyhoo', - custom: {}, + custom: { + user0: 'a0', + user1: 'a1', + user2: 'a2', + user3: 'a3', + }, colour: 'red', type: 'event', cue: '101', }, - { + 'event-b': { + id: 'event-b', //timeStart: 32400000, //timeEnd: 34200000, title: 'A song from the hearth', @@ -1199,9 +448,19 @@ describe('parseExcel()', () => { type: 'event', cue: '102', }, - ]; + }); + }); - const parsedData = parseExcel(testdata, {}, importMap); + it('parses a file without custom fields', () => { + // partial import map with only custom fields + const importMap = { + custom: { + niu1: 'niu1', + niu2: 'niu2', + }, + }; + + const parsedData = parseExcel(dataFromExcelTemplate, {}, 'testSheet', importMap); expect(parsedData.customFields).toStrictEqual({ niu1: { type: 'string', @@ -1214,270 +473,93 @@ describe('parseExcel()', () => { label: 'niu2', }, }); - expect(parsedData.rundown.length).toBe(2); - expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]); - expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]); + expect(parsedData.rundown.order).toMatchObject(['event-a', 'event-b']); + expect(parsedData.rundown.entries['event-a']).toMatchObject({ + //timeStart: 28800000, + //timeEnd: 32410000, + id: 'event-a', + title: 'Guest Welcome', + timerType: 'count-down', + endAction: 'none', + isPublic: true, + skip: false, + note: 'Ballyhoo', + custom: {}, + colour: 'red', + type: 'event', + cue: '101', + }); + expect(parsedData.rundown.entries['event-b']).toMatchObject({ + //timeStart: 32400000, + //timeEnd: 34200000, + id: 'event-b', + title: 'A song from the hearth', + timerType: 'clock', + endAction: 'load-next', + isPublic: false, + skip: true, + note: 'Rainbow chase', + custom: {}, + colour: '#F00', + type: 'event', + cue: '102', + }); }); it('ignores unknown event types', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '1899-12-30T07:00:00.000Z', - '1899-12-30T08:00:10.000Z', - 'Guest Welcome', - '', - 'skip', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], + ['Title', 'Timer type'], + ['Guest Welcome', 'x'], + ['A song from the hearth', 'clock'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(1); - expect((result.rundown.at(0) as OntimeEvent).title).toBe('A song from the hearth'); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + + expect(result.rundown.order.length).toBe(1); + expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth'); }); it('imports blocks', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Timer type', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - [ - '', - '', - '', - '', - 'block', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - 'clock', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], + ['Title', 'Timer type'], + ['a block', 'block'], + ['an event', 'clock'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(2); - expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Block); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + + expect(result.rundown.order.length).toBe(2); + expect((firstEvent as OntimeEvent).type).toBe(SupportedEvent.Block); }); it('imports as events if there is no timer type column', () => { - const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - ], - ['', '', '', '', 'x', '', 'Ballyhoo', 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'red', 101], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - ], - [], - ]; + const testdata = [['Title'], ['no timer type'], ['also no timer type']]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(2); - expect(result.rundown[0]).toMatchObject({ + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const secondEvent = result.rundown.entries[result.rundown.order[1]]; + + expect(result.rundown.order.length).toBe(2); + expect(firstEvent).toMatchObject({ type: SupportedEvent.Event, timerType: TimerType.CountDown, }); - expect(result.rundown[1]).toMatchObject({ + + expect(secondEvent).toMatchObject({ type: SupportedEvent.Event, timerType: TimerType.CountDown, }); @@ -1485,289 +567,142 @@ describe('parseExcel()', () => { it('imports as events if timer type is empty or has whitespace', () => { const testdata = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'test0', - 'test1', - 'test2', - 'test3', - 'test4', - 'test5', - 'test6', - 'test7', - 'test8', - 'test9', - 'Colour', - 'cue', - 'Timer type', - ], - [ - '', - '', - '', - '', - 'x', - '', - 'Ballyhoo', - 'a0', - 'a1', - 'a2', - 'a3', - 'a4', - 'a5', - 'a6', - 'a7', - 'a8', - 'a9', - 'red', - 101, - ' ', - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 102, - undefined, - ], - [ - '1899-12-30T08:00:00.000Z', - '1899-12-30T08:30:00.000Z', - 'A song from the hearth', - 'load-next', - '', - 'x', - 'Rainbow chase', - 'b0', - '', - '', - '', - '', - 'b5', - '', - '', - '', - '', - '#F00', - 103, - ' count-up ', - ], - [], + ['Title', 'Timer type'], + ['first', ' '], + ['second', undefined], + ['third', ' count-up '], ]; const importMap = { - worksheet: 'event schedule', - timeStart: 'time start', - timeEnd: 'time end', - duration: 'duration', - cue: 'cue', title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testdata, {}, importMap); - expect(result.rundown.length).toBe(3); - expect((result.rundown.at(0) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(0) as OntimeEvent).timerType).toBe(TimerType.CountDown); - expect((result.rundown.at(1) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(1) as OntimeEvent).timerType).toBe(TimerType.CountDown); - expect((result.rundown.at(2) as OntimeEvent).type).toBe(SupportedEvent.Event); - expect((result.rundown.at(2) as OntimeEvent).timerType).toBe(TimerType.CountUp); + const result = parseExcel(testdata, {}, 'testSheet', importMap); + const firstEvent = result.rundown.entries[result.rundown.order[0]]; + const secondEvent = result.rundown.entries[result.rundown.order[1]]; + const thirdEvent = result.rundown.entries[result.rundown.order[2]]; + expect(result.rundown.order.length).toBe(3); + expect(firstEvent).toMatchObject({ title: 'first', type: SupportedEvent.Event, timerType: TimerType.CountDown }); + expect(secondEvent).toMatchObject({ title: 'second', type: SupportedEvent.Event, timerType: TimerType.CountDown }); + expect(thirdEvent).toMatchObject({ title: 'third', type: SupportedEvent.Event, timerType: TimerType.CountUp }); }); it('am/pm conversion to 24h', () => { const testData = [ - ['Time Start', 'Time End', 'Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour', 'cue'], - ['4:30:00', '4:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00', '10:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['16:30:00', '16:36:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['21:45:00', '22:56:00', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['4:30:00AM', '4:36:00AM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00AM', '10:56:00AM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - ['4:30:00PM', '4:36:00PM', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], - ['9:45:00PM', '10:56:00PM', 'Green grass', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103], - [], + ['Time Start', 'Time End', 'ID'], + ['4:30:00', '4:36:00', 'event-1'], + ['9:45:00', '10:56:00', 'event-2'], + ['16:30:00', '16:36:00', 'event-3'], + ['21:45:00', '22:56:00', 'event-4'], + ['4:30:00AM', '4:36:00AM', 'event-5'], + ['9:45:00AM', '10:56:00AM', 'event-6'], + ['4:30:00PM', '4:36:00PM', 'event-7'], + ['9:45:00PM', '10:56:00PM', 'event-8'], ]; const importMap = { - worksheet: 'event schedule', timeStart: 'time start', timeEnd: 'time end', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, + id: 'id', }; - const result = parseExcel(testData, {}, importMap); - const { rundown } = parseRundown(result); - const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[]; - expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); - expect((events.at(1) as OntimeEvent).timeStart).toEqual(35100000); - expect((events.at(2) as OntimeEvent).timeStart).toEqual(59400000); - expect((events.at(3) as OntimeEvent).timeStart).toEqual(78300000); - expect((events.at(4) as OntimeEvent).timeStart).toEqual(16200000); - expect((events.at(5) as OntimeEvent).timeStart).toEqual(35100000); - expect((events.at(6) as OntimeEvent).timeStart).toEqual(59400000); - expect((events.at(7) as OntimeEvent).timeStart).toEqual(78300000); + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(8); + expect(result.rundown.entries['event-1']).toMatchObject({ + timeStart: 16200000, + timeEnd: 16560000, + }); + expect(result.rundown.entries['event-2']).toMatchObject({ + timeStart: 35100000, + timeEnd: 39360000, + }); + expect(result.rundown.entries['event-3']).toMatchObject({ + timeStart: 59400000, + timeEnd: 59760000, + }); + expect(result.rundown.entries['event-4']).toMatchObject({ + timeStart: 78300000, + timeEnd: 82560000, + }); + expect(result.rundown.entries['event-5']).toMatchObject({ + timeStart: 16200000, + timeEnd: 16560000, + }); + expect(result.rundown.entries['event-6']).toMatchObject({ + timeStart: 35100000, + timeEnd: 39360000, + }); + expect(result.rundown.entries['event-7']).toMatchObject({ + timeStart: 59400000, + timeEnd: 59760000, + }); + expect(result.rundown.entries['event-8']).toMatchObject({ + timeStart: 78300000, + timeEnd: 82560000, + }); }); it('handle leading and trailing whitespace', () => { const testData = [ - ['Time Start', 'Time End', ' Title', 'End Action', 'Public', 'Skip', 'Notes', 'Colour ', 'cue'], - ['4:30:00', '4:30:00', 'A song from the hearth', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102], + [' ID', ' title ', 'Colour '], // <--- leading and trailing white space + ['event-a', 'title', '#F00'], ]; const importMap = { - worksheet: 'event schedule', - timeStart: ' time start', - timeEnd: 'time end ', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', - timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, + id: 'id', + title: ' title', // <--- leading white space + colour: 'colour ', // <--- trailing white space }; - const result = parseExcel(testData, {}, importMap); - const { rundown } = parseRundown(result); - const events = rundown.filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[]; - expect((events.at(0) as OntimeEvent).timeStart).toEqual(16200000); //<--leading white space in MAP - expect((events.at(0) as OntimeEvent).timeEnd).toEqual(16200000); //<--trailing white space in MAP - expect((events.at(0) as OntimeEvent).title).toEqual('A song from the hearth'); //<--leading white space in Excel data - expect((events.at(0) as OntimeEvent).colour).toEqual('#F00'); //<--trailing white space in Excel data + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(1); + expect(result.rundown.entries['event-a']).toMatchObject({ + colour: '#F00', + id: 'event-a', + title: 'title', + }); }); it('parses link start and checks that is applicable', () => { const testData = [ - [ - 'Time Start', - 'Time End', - 'Title', - 'End Action', - 'Public', - 'Skip', - 'Notes', - 'Colour', - 'cue', - 'Link Start', - 'Timer type', - ], - ['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'], - ['9:45:00', '10:56:00', 'B', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], - ['10:00:00', '16:36:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], // <-- incorrect start times are overridden - ['21:45:00', '22:56:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], - ['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'], - ['00:0:00', '23:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], // <-- link past blocks - [], + ['Time Start', 'Time End', 'ID', 'Link Start', 'Timer type'], + ['4:30:00', '9:45:00', 'A', '', 'count-down'], + ['9:45:00', '10:56:00', 'B', 'x', 'count-down'], + ['10:00:00', '16:36:00', 'C', 'x', 'count-down'], + ['21:45:00', '22:56:00', 'D', '', 'count-down'], + ['', '', 'BLOCK', 'x', 'block'], // <-- block with link + ['00:0:00', '23:56:00', 'E', 'x', 'count-down'], // <-- link past blocks ]; const importMap = { - worksheet: 'event schedule', timeStart: 'time start', - linkStart: 'link start', timeEnd: 'time end', - duration: 'duration', - cue: 'cue', - title: 'title', - isPublic: 'public', - skip: 'skip', - note: 'notes', - colour: 'colour', - endAction: 'end action', + linkStart: 'link start', + id: 'id', timerType: 'timer type', - timeWarning: 'warning time', - timeDanger: 'danger time', - custom: {}, }; - const result = parseExcel(testData, {}, importMap); - const parseResult = parseRundown(result); + const result = parseExcel(testData, {}, 'testSheet', importMap); + expect(result.rundown.order.length).toBe(6); + expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'BLOCK', 'E']); - cache.init(parseResult.rundown, parseResult.customFields); - const { rundown, order } = cache.get(); - - const firstId = order.at(0); // A - const secondId = order.at(1); // B - const thirdId = order.at(2); // C - const fourthId = order.at(3); // D - const fifthId = order.at(4); // Block - const sixthId = order.at(5); // E - - if (!firstId || !secondId || !thirdId || !fourthId || !fifthId || !sixthId) { - throw new Error('Unexpected value'); - } - - expect(rundown).toMatchObject({ - [firstId]: { - title: 'A', - timeStart: 16200000, - }, - [secondId]: { - title: 'B', - timeStart: (rundown[firstId] as OntimeEvent).timeEnd, - linkStart: (rundown[firstId] as OntimeEvent).id, - }, - [thirdId]: { - title: 'C', - timeStart: (rundown[secondId] as OntimeEvent).timeEnd, - linkStart: (rundown[secondId] as OntimeEvent).id, - }, - [fourthId]: { - title: 'D', - timeStart: 78300000, + expect(result.rundown.entries).toMatchObject({ + A: { linkStart: null, }, - [fifthId]: { - title: 'BLOCK', + B: { + linkStart: 'true', // <--- this will be populated by the cache generation + }, + C: { + linkStart: 'true', // <--- this will be populated by the cache generation + }, + D: { + linkStart: null, + }, + BLOCK: { type: SupportedEvent.Block, }, - [sixthId]: { - title: 'E', - timeStart: (rundown[fourthId] as OntimeEvent).timeEnd, - linkStart: (rundown[fourthId] as OntimeEvent).id, + E: { + linkStart: 'true', // <--- this will be populated by the cache generation }, }); }); @@ -1775,147 +710,79 @@ describe('parseExcel()', () => { it('#971 BUG: parses time fields and booleans', () => { const testData = [ [ - 'Cue', - 'Colour', + 'ID', 'Time Start', 'Time End', 'Duration', 'Link Start', - 'Title', - 'Note', 'Timer Type', 'End Action', 'Warning time', 'Danger time', - 'Public', - 'Skip', ], [ 'SETUP', - '', '1899-12-30T07:15:00.000Z', '1899-12-30T08:30:00.000Z', '', 'false', - 'Setup', - '', 'count-down', 'none', '15', '00:05:00', - 'FALSE', - 'TRUE', ], [ 'MEET1', - '#779BE7', '1899-12-30T08:30:00.000Z', '1899-12-30T10:00:00.000Z', '', 'false', - 'Meeting 1', - '', 'count-down', 'none', 15, '00:05:00', - 'TRUE', - 'FALSE', ], - [ - 'MEET2', - '#779BE7', - '1899-12-30T10:00:00.000Z', - '', - '60', - 'false', - 'Meeting 2', - '', - 'count-down', - 'none', - '13', - '5', - 'TRUE', - 'FALSE', - ], - [ - 'lunch', - '#77C785', - '', - '1899-12-30T11:30:00.000Z', - '', - 'true', - 'Lunch', - '', - 'count-down', - 'none', - 13, - 5, - 'FALSE', - 'FALSE', - ], - [ - 'MEET3', - '#779BE7', - '1899-12-30T11:30:00.000Z', - '', - 90, - false, - 'Meeting 3', - '', - 'count-up', - 'none', - '11', - 5, - 'TRUE', - 'FALSE', - ], - ['MEET4', '#779BE7', '', '', 30, true, 'Meeting 4', '', 'count-up', 'none', 11, '00:05:00', 'TRUE', 'FALSE'], + ['MEET2', '1899-12-30T10:00:00.000Z', '', '60', 'false', 'count-down', 'none', '13', '5'], + ['lunch', '', '1899-12-30T11:30:00.000Z', '', 'true', 'count-down', 'none', 13, 5], + ['MEET3', '1899-12-30T11:30:00.000Z', '', 90, false, 'count-up', 'none', '11', 5], + ['MEET4', '', '', 30, true, 'count-up', 'none', 11, '00:05:00'], ]; - const parsedData = parseExcel(testData, {}); - const { rundown } = parsedData; + const parsedData = parseExcel(testData, {}, 'bug-report'); // '15' as a string is parsed by smart time entry as minutes - expect(rundown[0]).toMatchObject({ - cue: 'SETUP', + expect(parsedData.rundown.entries['SETUP']).toMatchObject({ timeWarning: 15 * MILLIS_PER_MINUTE, }); // elements in bug report // 15 is a number, in which case we parse it as a minutes value - expect(rundown[1]).toMatchObject({ - cue: 'MEET1', + expect(parsedData.rundown.entries['MEET1']).toMatchObject({ timeWarning: 15 * MILLIS_PER_MINUTE, }); // in the case where a string is passed, we need to check whether it is an ISO 8601 date - expect(rundown[2]).toMatchObject({ - cue: 'MEET2', + expect(parsedData.rundown.entries['MEET2']).toMatchObject({ duration: 60 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[3]).toMatchObject({ - cue: 'lunch', + expect(parsedData.rundown.entries['lunch']).toMatchObject({ timeWarning: 13 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[4]).toMatchObject({ - cue: 'MEET3', + expect(parsedData.rundown.entries['MEET3']).toMatchObject({ duration: 90 * MILLIS_PER_MINUTE, - linkStart: false, + linkStart: null, timeWarning: 11 * MILLIS_PER_MINUTE, timeDanger: 5 * MILLIS_PER_MINUTE, }); - expect(rundown[5]).toMatchObject({ - cue: 'MEET4', + expect(parsedData.rundown.entries['MEET4']).toMatchObject({ duration: 30 * MILLIS_PER_MINUTE, timeWarning: 11 * MILLIS_PER_MINUTE, - // if we get a boolean, we should just use that - linkStart: true, + linkStart: 'true', // if we get a boolean, we should just use that }); }); }); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index b0313887d..35f6fcdfa 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -1,45 +1,171 @@ -import { - CustomFields, - DatabaseModel, - OntimeEvent, - OntimeRundown, - Settings, - SupportedEvent, - URLPreset, -} from 'ontime-types'; +import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEvent, URLPreset } from 'ontime-types'; + +import { defaultRundown } from '../../models/dataModel.js'; import { parseCustomFields, parseProject, parseRundown, + parseRundowns, parseSettings, parseUrlPresets, parseViewSettings, sanitiseCustomFields, } from '../parserFunctions.js'; -describe('parseRundown()', () => { - it('returns an empty array if no rundown is given', () => { +describe('parseRundowns()', () => { + it('returns a default project rundown if nothing is given', () => { const errorEmitter = vi.fn(); - const result = parseRundown({}, errorEmitter); - expect(result.rundown).toEqual([]); + const result = parseRundowns({}, errorEmitter); expect(result.customFields).toEqual({}); + expect(result.rundowns).toStrictEqual({ default: defaultRundown }); + // one for not having custom fields + // one for not having a rundown expect(errorEmitter).toHaveBeenCalledTimes(2); }); + it('ensures the rundown IDs are consistent', () => { + const errorEmitter = vi.fn(); + const r1 = { ...defaultRundown, id: '1' }; + const r2 = { ...defaultRundown, id: '2' }; + const result = parseRundowns( + { + rundowns: { + '1': r1, + '3': r2, + }, + }, + errorEmitter, + ); + expect(result.rundowns).toMatchObject({ + '1': r1, + '2': r2, + }); + // one for not having a rundown + expect(errorEmitter).toHaveBeenCalledTimes(1); + }); +}); + +describe('parseRundown()', () => { it('parses data, skipping invalid results', () => { const errorEmitter = vi.fn(); - const rundown = [ - { id: '1', type: SupportedEvent.Event, title: 'test', skip: false }, // OK - { id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false }, // duplicate ID - {}, // no data - { id: '2', title: 'test 2', skip: false }, // no type - ] as OntimeRundown; - const { rundown: parsedRundown } = parseRundown({ rundown, customFields: {} }, errorEmitter); - expect(parsedRundown.length).toEqual(1); - expect(parsedRundown.at(0)).toMatchObject({ id: '1', type: SupportedEvent.Event, title: 'test', skip: false }); + const rundown = { + id: '', + title: '', + order: ['1', '2', '3', '4'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, title: 'test', skip: false } as OntimeEvent, // OK + '2': { id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID + '3': {} as OntimeEvent, // no data + '4': { id: '4', title: 'test 2', skip: false } as OntimeEvent, // no type + }, + revision: 1, + } as Rundown; + + const parsedRundown = parseRundown(rundown, {}, errorEmitter); + expect(parsedRundown.id).not.toBe(''); + expect(parsedRundown.id).toBeTypeOf('string'); + expect(parsedRundown.order.length).toEqual(1); + expect(parsedRundown.order).toEqual(['1']); + expect(parsedRundown.entries).toMatchObject({ + '1': { + id: '1', + type: SupportedEvent.Event, + title: 'test', + skip: false, + }, + }); expect(errorEmitter).toHaveBeenCalled(); }); + + it('stringifies necessary values', () => { + const rundown = { + id: '', + title: '', + order: ['1', '2'], + entries: { + // @ts-expect-error -- testing external data which could be incorrect + '1': { id: '1', type: SupportedEvent.Event, cue: 101 } as OntimeEvent, + // @ts-expect-error -- testing external data which could be incorrect + '2': { id: '2', type: SupportedEvent.Event, cue: 101.1 } as OntimeEvent, + }, + revision: 1, + } as Rundown; + + expect(parseRundown(rundown, {})).toMatchObject({ + entries: { + '1': { + cue: '101', + }, + '2': { + cue: '101.1', + }, + }, + }); + }); + + it('detects duplicate Ids', () => { + const rundown = { + id: '', + title: '', + order: ['1', '1'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + }, + revision: 1, + } as Rundown; + + const parsedRundown = parseRundown(rundown, {}); + expect(parsedRundown.order.length).toEqual(1); + expect(Object.keys(parsedRundown.entries).length).toEqual(1); + }); + + it('completes partial datasets', () => { + const rundown = { + id: 'test', + title: '', + order: ['1', '2'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + }, + revision: 1, + } as Rundown; + + const parsedRundown = parseRundown(rundown, {}); + expect(parsedRundown.order.length).toEqual(2); + expect(parsedRundown.entries).toMatchObject({ + '1': { + title: '', + cue: '1', + custom: {}, + }, + '2': { + title: '', + cue: '2', + custom: {}, + }, + }); + }); + + it('handles empty events', () => { + const rundown = { + id: 'test', + title: '', + order: ['1', '2', '3', '4'], + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + 'not-mentioned': {} as OntimeEvent, + }, + revision: 1, + } as Rundown; + + const parsedRundown = parseRundown(rundown, {}); + expect(parsedRundown.order.length).toEqual(2); + expect(Object.keys(parsedRundown.entries).length).toEqual(2); + }); }); describe('parseProject()', () => { @@ -77,7 +203,6 @@ describe('parseProject()', () => { projectLogo: null, custom: [], }); - expect(errorEmitter).not.toHaveBeenCalled(); }); }); @@ -87,9 +212,17 @@ describe('parseSettings()', () => { }); it('returns an a base model as long as we have the app and version', () => { - const minimalSettings = { app: 'ontime', version: '1' } as Settings; - const result = parseSettings({ settings: minimalSettings }); + const result = parseSettings({ settings: { app: 'ontime', version: '1' } as Settings }); expect(result).toBeTypeOf('object'); + expect(result).toMatchObject({ + app: 'ontime', + version: expect.any(String), + serverPort: 4001, + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }); }); }); @@ -221,17 +354,6 @@ describe('sanitiseCustomFields()', () => { expect(sanitationResult).toStrictEqual(expectedCustomFields); }); - it('allow old keys', () => { - const customFields: CustomFields = { - test: { label: 'Test', type: 'string', colour: 'red' }, - }; - const expectedCustomFields: CustomFields = { - test: { label: 'Test', type: 'string', colour: 'red' }, - }; - const sanitationResult = sanitiseCustomFields(customFields); - expect(sanitationResult).toStrictEqual(expectedCustomFields); - }); - it('labels with space', () => { const customFields: CustomFields = { Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, @@ -262,100 +384,120 @@ describe('sanitiseCustomFields()', () => { describe('parseRundown() linking', () => { it('returns linked events', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', '2'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, skip: false, } as OntimeEvent, - { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[1]).toMatchObject({ - id: '2', - linkStart: '1', + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: ['1', '2'], + entries: { + '2': { + linkStart: '1', + }, + }, }); }); it('returns unlinked if no previous', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', '2'], + entries: { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[0]).toMatchObject({ - id: '2', - linkStart: null, + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: ['2'], + entries: { + '2': { + linkStart: null, + }, + }, }); }); it('returns linked events past blocks and delays', () => { - const data: Partial = { - rundown: [ - { + const rundown: Rundown = { + id: '', + title: '', + revision: 1, + order: ['1', 'delay1', '2', 'block1', '3'], + entries: { + '1': { id: '1', type: SupportedEvent.Event, skip: false, } as OntimeEvent, - { + delay1: { id: 'delay1', type: SupportedEvent.Delay, duration: 0, }, - { + '2': { id: '2', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - { + block1: { id: 'block1', type: SupportedEvent.Block, title: '', - }, - { + } as OntimeBlock, + '3': { id: '3', type: SupportedEvent.Event, linkStart: 'true', skip: false, } as OntimeEvent, - ], - customFields: {}, + }, }; - const result = parseRundown(data); - expect(result.rundown[0]).toMatchObject({ - id: '1', - cue: '1', - }); - // skip delay - expect(result.rundown[2]).toMatchObject({ - id: '2', - cue: '2', - linkStart: '1', - }); - // skip block - expect(result.rundown[4]).toMatchObject({ - id: '3', - cue: '3', - linkStart: '2', + const result = parseRundown(rundown, {}); + expect(result).toMatchObject({ + order: rundown.order, + entries: { + '1': { + id: '1', + cue: '1', + }, + '2': { + id: '2', + cue: '2', + linkStart: '1', + }, + '3': { + id: '3', + cue: '3', + linkStart: '2', + }, + }, }); }); }); diff --git a/apps/server/src/utils/__tests__/time.test.ts b/apps/server/src/utils/__tests__/time.test.ts index 31febd9a0..6367874b1 100644 --- a/apps/server/src/utils/__tests__/time.test.ts +++ b/apps/server/src/utils/__tests__/time.test.ts @@ -2,68 +2,38 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { parseExcelDate } from '../time.js'; describe('parseExcelDate', () => { + // TODO: our parsing currently does not use UTC, so the tests can not be done in CI describe.todo('parses a valid date string as expected from excel', () => { - const testCases = [ - { - fromExcel: '1899-12-30T00:00:00.000Z', - expected: 3600000, - }, - { - fromExcel: '1899-12-30T00:10:00.000Z', - expected: 4200000, - }, - { - fromExcel: '1899-12-30T01:00:00.000Z', - expected: 7200000, - }, - { - fromExcel: '1899-12-30T07:00:00.000Z', - expected: 28800000, - }, - { - fromExcel: '1899-12-30T08:00:10.000Z', - expected: 32410000, - }, - { - fromExcel: '1899-12-30T08:30:00.000Z', - expected: 34200000, - }, - ]; - - for (const scenario of testCases) { - it(`handles ${scenario.fromExcel}`, () => { - expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected); - }); - } - }); - - describe('parses a time string that passes validation', () => { - const validFields = ['10:00:00', '10:00', '10:00AM', '10:00am', '10:00PM', '10:00pm']; - validFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).not.toBe(0); - }); + test.each([ + ['1899-12-30T00:00:00.000Z', 3600000], + ['1899-12-30T00:10:00.000Z', 4200000], + ['1899-12-30T01:00:00.000Z', 7200000], + ['1899-12-30T07:00:00.000Z', 28800000], + ['1899-12-30T08:00:10.000Z', 32410000], + ['1899-12-30T08:30:00.000Z', 34200000], + ])(`handles %s`, (fromExcel, expected) => { + expect(parseExcelDate(fromExcel)).toBe(expected); }); }); + describe('parses a time string that passes validation', () => { + test.each([['10:00:00'], ['10:00'], ['10:00AM'], ['10:00am'], ['10:00PM'], ['10:00pm']])( + `handles %s`, + (fromExcel) => { + expect(parseExcelDate(fromExcel)).not.toBe(0); + }, + ); + }); + describe('uses numeric fields as minutes', () => { - const invalidFields = [1, 10, 100]; - invalidFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).toBe(field * MILLIS_PER_MINUTE); - }); + test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => { + expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE); }); }); describe('returns 0 on other strings', () => { - const invalidFields = ['test', '']; - invalidFields.forEach((field) => { - it(`handles ${field}`, () => { - const millis = parseExcelDate(field); - expect(millis).toBe(0); - }); + test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => { + expect(parseExcelDate(fromExcel)).toBe(0); }); }); }); diff --git a/apps/server/src/utils/fileManagement.ts b/apps/server/src/utils/fileManagement.ts index 759a6ee29..d4da7c441 100644 --- a/apps/server/src/utils/fileManagement.ts +++ b/apps/server/src/utils/fileManagement.ts @@ -106,7 +106,7 @@ export async function copyDirectory(src: string, dest: string) { } } -/** +/** * workaround avoids origin errors in docker deployments * EXDEV cross-device link not permitted */ diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index dce11eae9..bf7a79c4f 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -13,11 +13,12 @@ import { import { CustomFields, DatabaseModel, - EventCustomFields, + EntryCustomFields, isOntimeBlock, LogOrigin, + OntimeBlock, OntimeEvent, - OntimeRundown, + Rundown, SupportedEvent, TimerType, TimeStrategy, @@ -28,17 +29,14 @@ import { logger } from '../classes/Logger.js'; import { event as eventDef } from '../models/eventsDefinition.js'; import { makeString } from './parserUtils.js'; -import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; +import { parseProject, parseRundowns, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; +import { Merge } from 'ts-essentials'; export type ErrorEmitter = (message: string) => void; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; -type ExcelData = Pick & { - rundownMetadata: Record; -}; - function parseBooleanString(value: unknown): boolean { if (typeof value === 'boolean') { return value; @@ -83,9 +81,14 @@ export function getCustomFieldData( export const parseExcel = ( excelData: unknown[][], existingCustomFields: CustomFields, + sheetName: string = 'Rundown from excel', options?: Partial, -): ExcelData => { - const rundownMetadata = {}; +): { + rundown: Rundown; + customFields: CustomFields; + rundownMetadata: Record; +} => { + const rundownMetadata: Record = {}; const importMap: ImportMap = { ...defaultImportMap, ...options }; for (const [key, value] of Object.entries(importMap)) { @@ -95,7 +98,13 @@ export const parseExcel = ( } const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields); - const rundown: OntimeRundown = []; + const rundown: Rundown = { + id: generateId(), + title: sheetName, + order: [], + entries: {}, + revision: 0, + }; // title stuff: strings let titleIndex: number | null = null; @@ -205,8 +214,8 @@ export const parseExcel = ( }, } as const; - const event: any = {}; - const eventCustomFields: EventCustomFields = {}; + const entry: Partial> = {}; + const entryCustomFields: EntryCustomFields = {}; for (let j = 0; j < row.length; j++) { const column = row[j]; @@ -214,48 +223,50 @@ export const parseExcel = ( if (j === timerTypeIndex) { const maybeTimeType = makeString(column, ''); if (maybeTimeType === 'block') { - event.type = SupportedEvent.Block; + // we leave this as a clue for the object filtering later on + entry.type = SupportedEvent.Block; } else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) { - event.type = SupportedEvent.Event; - event.timerType = validateTimerType(maybeTimeType); + // @ts-expect-error -- we leave this as a clue for the object filtering later on + entry.type = SupportedEvent.Event; + entry.timerType = validateTimerType(maybeTimeType); } else { // if it is not a block or a known type, we dont import it return; } } else if (j === titleIndex) { - event.title = makeString(column, ''); + entry.title = makeString(column, ''); } else if (j === timeStartIndex) { - event.timeStart = parseExcelDate(column); + entry.timeStart = parseExcelDate(column); } else if (j === linkStartIndex) { - event.linkStart = parseBooleanString(column); + entry.linkStart = parseBooleanString(column) ? 'true' : null; } else if (j === timeEndIndex) { - event.timeEnd = parseExcelDate(column); + entry.timeEnd = parseExcelDate(column); } else if (j === durationIndex) { - event.duration = parseExcelDate(column); + entry.duration = parseExcelDate(column); } else if (j === cueIndex) { - event.cue = makeString(column, ''); + entry.cue = makeString(column, ''); } else if (j === countToEndIndex) { - event.countToEnd = parseBooleanString(column); + entry.countToEnd = parseBooleanString(column); } else if (j === isPublicIndex) { - event.isPublic = parseBooleanString(column); + entry.isPublic = parseBooleanString(column); } else if (j === skipIndex) { - event.skip = parseBooleanString(column); + entry.skip = parseBooleanString(column); } else if (j === notesIndex) { - event.note = makeString(column, ''); + entry.note = makeString(column, ''); } else if (j === endActionIndex) { - event.endAction = validateEndAction(column); + entry.endAction = validateEndAction(column); } else if (j === timeWarningIndex) { - event.timeWarning = parseExcelDate(column); + entry.timeWarning = parseExcelDate(column); } else if (j === timeDangerIndex) { - event.timeDanger = parseExcelDate(column); + entry.timeDanger = parseExcelDate(column); } else if (j === colourIndex) { - event.colour = makeString(column, ''); + entry.colour = makeString(column, ''); } else if (j === entryIdIndex) { - event.id = encodeURIComponent(makeString(column, undefined)); + entry.id = encodeURIComponent(makeString(column, undefined)); } else if (j in customFieldIndexes) { const importKey = customFieldIndexes[j]; const ontimeKey = customFieldImportKeys[importKey]; - eventCustomFields[ontimeKey] = makeString(column, ''); + entryCustomFields[ontimeKey] = makeString(column, ''); } else { // 2. if there is no flag, lets see if we know the field type if (typeof column === 'string') { @@ -282,20 +293,32 @@ export const parseExcel = ( } } - // if any data was found in row, push to array - const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; - if (keysFound > 0) { - // if it is a Block type drop all other filed - if (isOntimeBlock(event)) { - rundown.push({ type: event.type, id: event.id, title: event.title }); - } else { - if (timerTypeIndex === null) { - event.timerType = TimerType.CountDown; - event.type = SupportedEvent.Event; - } - rundown.push({ ...event, custom: { ...eventCustomFields } }); - } + // if we didnt find any keys (empty row, or some other data), skip making an event + const keysFound = Object.keys(entry).length + Object.keys(entryCustomFields).length; + if (keysFound === 0) { + return; } + + const id = entry.id || generateId(); + // from excel, we can only get blocks and events + if (isOntimeBlock(entry)) { + const block: OntimeBlock = { ...entry, custom: { ...entryCustomFields } }; + rundown.order.push(id); + rundown.entries[id] = block; + return; + } + + const event = { + ...entry, + custom: { ...entryCustomFields }, + type: SupportedEvent.Event, + } as OntimeEvent; + + if (timerTypeIndex === null) { + event.timerType = TimerType.CountDown; + } + rundown.order.push(id); + rundown.entries[id] = event; }); return { @@ -327,11 +350,10 @@ export function parseDatabaseModel(jsonData: Partial): { data: Da }; // we need to parse the custom fields first so they can be used in validating events - // TODO: can we improve the readability of the error? - const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown')); + const { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown')); const data: DatabaseModel = { - rundown, + rundowns, project: parseProject(jsonData, makeEmitError('Project')), settings, viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')), @@ -394,6 +416,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial, emitError?: ErrorEmitter, -): { customFields: CustomFields; rundown: OntimeRundown } { +): { customFields: CustomFields; rundowns: ProjectRundowns } { // check custom fields first const parsedCustomFields = parseCustomFields(data, emitError); - if (!data.rundown) { + if (!data.rundowns || isObjectEmpty(data.rundowns)) { emitError?.('No data found to import'); - return { customFields: parsedCustomFields, rundown: [] }; + return { + customFields: parsedCustomFields, + rundowns: { + default: { + ...defaultRundown, + }, + }, + }; } - console.log('Found rundown, importing...'); + const parsedRundowns: ProjectRundowns = {}; + const iterableRundownsIds = Object.keys(data.rundowns); + + // parse all the rundowns individually + for (const id of iterableRundownsIds) { + console.log('Found rundown, importing...'); + const rundown = data.rundowns[id]; + const parsedRundown = parseRundown(rundown, parsedCustomFields, emitError); + parsedRundowns[parsedRundown.id] = parsedRundown; + } + + return { customFields: parsedCustomFields, rundowns: parsedRundowns }; +} + +/** + * Parses and validates a single project rundown along with given project custom fields + */ +export function parseRundown( + rundown: Rundown, + parsedCustomFields: Readonly, + emitError?: ErrorEmitter, +): Rundown { + const parsedRundown: Rundown = { + id: rundown.id || generateId(), + title: rundown.title ?? '', + entries: {}, + order: [], + revision: rundown.revision ?? 1, + }; - const rundown: OntimeRundown = []; let eventIndex = 0; let previousId: string | null = null; - const ids: string[] = []; - for (const event of data.rundown) { - if (ids.includes(event.id)) { + for (let i = 0; i < rundown.order.length; i++) { + const entryId = rundown.order[i]; + const event = rundown.entries[entryId]; + if (event === undefined) { + emitError?.('Could not find referenced event, skipping'); + continue; + } + + if (parsedRundown.order.includes(event.id)) { emitError?.('ID collision on event import, skipping'); continue; } - const id = event.id || generateId(); + const id = entryId; let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; if (isOntimeEvent(event)) { - const maybeEvent = { ...event, id }; + const maybeEvent = { ...event }; if (event.linkStart) { maybeEvent.linkStart = previousId; @@ -78,20 +120,29 @@ export function parseRundown( } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; } else if (isOntimeBlock(event)) { - newEvent = { ...blockDef, title: event.title, id }; + newEvent = { + ...blockDef, + title: event.title, + note: event.note, + events: event.events?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [], + skip: event.skip, + colour: event.colour, + custom: { ...event.custom }, + id, + }; } else { emitError?.('Unknown event type, skipping'); continue; } if (newEvent) { - rundown.push(newEvent); - ids.push(id); + parsedRundown.entries[id] = newEvent; + parsedRundown.order.push(id); } } - console.log(`Uploaded rundown with ${rundown.length} entries`); - return { customFields: parsedCustomFields, rundown }; + console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`); + return parsedRundown; } /** @@ -216,10 +267,15 @@ export function sanitiseCustomFields(data: object): CustomFields { continue; } - const keyFromLabel = customFieldLabelToKey(field.label); - // Test label and key cohesion, but allow old lowercased keys to stay - // TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) - const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; + // Test label and key cohesion + const key = (() => { + const keyFromLabel = customFieldLabelToKey(field.label); + if (keyFromLabel === null) { + return originalKey; + } + return originalKey.toLowerCase() === keyFromLabel.toLowerCase() ? originalKey : keyFromLabel; + })(); + if (key in newCustomFields) { continue; } diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index b2d559fbe..7b31f910d 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -1,408 +1,469 @@ { - "rundown": [ - { - "id": "32d31", - "type": "event", - "title": "Albania", - "timeStart": 36000000, - "timeEnd": 37200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.01", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.01", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sekret", - "artist": "Ronela Hajati" + "rundowns": { + "demo": { + "id": "demo", + "title": "Eurovision Demo", + "order": [ + "32d31", + "21cd2", + "0b371", + "3cd28", + "e457f", + "01e85", + "1c420", + "b7737", + "d3a80", + "8276c", + "2340b", + "cb90b", + "503c4", + "5e965", + "bab4a", + "d3eb1" + ], + "entries": { + "32d31": { + "type": "event", + "id": "32d31", + "cue": "SF1.01", + "title": "Albania", + "note": "SF1.01", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 36000000, + "timeEnd": 37200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sekret", + "artist": "Ronela Hajati" + } + }, + "21cd2": { + "type": "event", + "id": "21cd2", + "cue": "SF1.02", + "title": "Latvia", + "note": "SF1.02", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 37500000, + "timeEnd": 38700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Eat Your Salad", + "artist": "Citi Zeni" + } + }, + "0b371": { + "type": "event", + "id": "0b371", + "cue": "SF1.03", + "title": "Lithuania", + "note": "SF1.03", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 39000000, + "timeEnd": 40200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sentimentai", + "artist": "Monika Liu" + } + }, + "3cd28": { + "type": "event", + "id": "3cd28", + "cue": "SF1.04", + "title": "Switzerland", + "note": "SF1.04", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 40500000, + "timeEnd": 41700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Boys Do Cry", + "artist": "Marius Bear" + } + }, + "e457f": { + "type": "event", + "id": "e457f", + "cue": "SF1.05", + "title": "Slovenia", + "note": "SF1.05", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 42000000, + "timeEnd": 43200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Disko", + "artist": "LPS" + } + }, + "01e85": { + "type": "block", + "id": "01e85", + "title": "Lunch break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "1c420": { + "type": "event", + "id": "1c420", + "cue": "SF1.06", + "title": "Ukraine", + "note": "SF1.06", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 47100000, + "timeEnd": 48300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 3900000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Stefania", + "artist": "Kalush Orchestra" + } + }, + "b7737": { + "type": "event", + "id": "b7737", + "cue": "SF1.07", + "title": "Bulgaria", + "note": "SF1.07", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 48600000, + "timeEnd": 49800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Intention", + "artist": "Intelligent Music Project" + } + }, + "d3a80": { + "type": "event", + "id": "d3a80", + "cue": "SF1.08", + "title": "Netherlands", + "note": "SF1.08", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 50100000, + "timeEnd": 51300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "De Diepte", + "artist": "S10" + } + }, + "8276c": { + "type": "event", + "id": "8276c", + "cue": "SF1.09", + "title": "Moldova", + "note": "SF1.09", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 51600000, + "timeEnd": 52800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Trenuletul", + "artist": "Zdob si Zdub" + } + }, + "2340b": { + "type": "event", + "id": "2340b", + "cue": "SF1.10", + "title": "Portugal", + "note": "SF1.10", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 53100000, + "timeEnd": 54300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Saudade Saudade", + "artist": "Maro" + } + }, + "cb90b": { + "type": "block", + "id": "cb90b", + "title": "Afternoon break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "503c4": { + "type": "event", + "id": "503c4", + "cue": "SF1.11", + "title": "Croatia", + "note": "SF1.11", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 56100000, + "timeEnd": 57300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 1800000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Guilty Pleasure", + "artist": "Mia Dimsic" + } + }, + "5e965": { + "type": "event", + "id": "5e965", + "cue": "SF1.12", + "title": "Denmark", + "note": "SF1.12", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 57600000, + "timeEnd": 58800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "The Show", + "artist": "Reddi" + } + }, + "bab4a": { + "type": "event", + "id": "bab4a", + "cue": "SF1.13", + "title": "Austria", + "note": "SF1.13", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 59100000, + "timeEnd": 60300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Halo", + "artist": "LUM!X & Pia Maria" + } + }, + "d3eb1": { + "type": "event", + "id": "d3eb1", + "cue": "SF1.14", + "title": "Greece", + "note": "SF1.14", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 60600000, + "timeEnd": 61800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Die Together", + "artist": "Amanda Tenfjord" + } + } + }, + "revision": 0 } }, - { - "id": "21cd2", - "type": "event", - "title": "Latvia", - "timeStart": 37500000, - "timeEnd": 38700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.02", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.02", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Eat Your Salad", - "artist": "Citi Zeni" - } - }, - { - "id": "0b371", - "type": "event", - "title": "Lithuania", - "timeStart": 39000000, - "timeEnd": 40200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.03", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.03", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sentimentai", - "artist": "Monika Liu" - } - }, - { - "id": "3cd28", - "type": "event", - "title": "Switzerland", - "timeStart": 40500000, - "timeEnd": 41700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.04", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.04", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Boys Do Cry", - "artist": "Marius Bear" - } - }, - { - "id": "e457f", - "type": "event", - "title": "Slovenia", - "timeStart": 42000000, - "timeEnd": 43200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.05", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.05", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Disko", - "artist": "LPS" - } - }, - { - "title": "Lunch break", - "type": "block", - "id": "01e85" - }, - { - "id": "1c420", - "type": "event", - "title": "Ukraine", - "timeStart": 47100000, - "timeEnd": 48300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.06", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.06", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Stefania", - "artist": "Kalush Orchestra" - } - }, - { - "id": "b7737", - "type": "event", - "title": "Bulgaria", - "timeStart": 48600000, - "timeEnd": 49800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.07", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.07", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Intention", - "artist": "Intelligent Music Project" - } - }, - { - "id": "d3a80", - "type": "event", - "title": "Netherlands", - "timeStart": 50100000, - "timeEnd": 51300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.08", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.08", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "De Diepte", - "artist": "S10" - } - }, - { - "id": "8276c", - "type": "event", - "title": "Moldova", - "timeStart": 51600000, - "timeEnd": 52800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.09", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.09", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Trenuletul", - "artist": "Zdob si Zdub" - } - }, - { - "id": "2340b", - "type": "event", - "title": "Portugal", - "timeStart": 53100000, - "timeEnd": 54300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.10", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.10", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Saudade Saudade", - "artist": "Maro" - } - }, - { - "title": "Afternoon break", - "type": "block", - "id": "cb90b" - }, - { - "id": "503c4", - "type": "event", - "title": "Croatia", - "timeStart": 56100000, - "timeEnd": 57300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.11", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.11", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Guilty Pleasure", - "artist": "Mia Dimsic" - } - }, - { - "id": "5e965", - "type": "event", - "title": "Denmark", - "timeStart": 57600000, - "timeEnd": 58800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.12", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.12", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "The Show", - "artist": "Reddi" - } - }, - { - "id": "bab4a", - "type": "event", - "title": "Austria", - "timeStart": 59100000, - "timeEnd": 60300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.13", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.13", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Halo", - "artist": "LUM!X & Pia Maria" - } - }, - { - "id": "d3eb1", - "type": "event", - "title": "Greece", - "timeStart": 60600000, - "timeEnd": 61800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.14", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.14", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Die Together", - "artist": "Amanda Tenfjord" - } - } -], "project": { "title": "Eurovision Song Contest", "description": "Turin 2022", @@ -415,7 +476,7 @@ }, "settings": { "app": "ontime", - "version": "3.10.2", + "version": "-", "serverPort": 4001, "editorKey": null, "operatorKey": null, @@ -423,12 +484,24 @@ "language": "en" }, "viewSettings": { - "overrideStyles": false, - "normalColor": "#ffffffcc", - "warningColor": "#FFAB33", "dangerColor": "#ED3333", "endMessage": "", - "freezeEnd": false + "freezeEnd": false, + "normalColor": "#ffffffcc", + "overrideStyles": false, + "warningColor": "#FFAB33" + }, + "customFields": { + "song": { + "label": "Song", + "type": "string", + "colour": "#339E4E" + }, + "artist": { + "label": "Artist", + "type": "string", + "colour": "#3E75E8" + } }, "urlPresets": [ { @@ -443,17 +516,5 @@ "oscPortIn": 8888, "triggers": [], "automations": {} - }, - "customFields": { - "song": { - "type": "string", - "colour": "", - "label": "song" - }, - "artist": { - "type": "string", - "colour": "", - "label": "artist" - } } } diff --git a/apps/spec/roll.md b/apps/spec/roll.md index b41664a41..00a19a5b2 100644 --- a/apps/spec/roll.md +++ b/apps/spec/roll.md @@ -12,7 +12,7 @@ It can be user either on its own, or as in conjunction with manual playback to a ## Implementation details ### starting to roll -> RuntimeService.roll(rundown: OntimeRundown) +> RuntimeService.roll(rundown) When calling the roll function, we try and find events to load. There should always be an event as long as the rundown is not empty. diff --git a/e2e/tests/000-upload-showfile.spec.ts b/e2e/tests/000-upload-showfile.spec.ts index 28b363b89..02b34fd0b 100644 --- a/e2e/tests/000-upload-showfile.spec.ts +++ b/e2e/tests/000-upload-showfile.spec.ts @@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test'; import { readFile } from 'fs/promises'; -const fileToUpload = 'e2e/tests/fixtures/test-db.json'; -const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json'; +const fileToUpload = 'e2e/tests/fixtures/e2e-test-db.json'; +const fileToDownload = 'e2e/tests/fixtures/tmp/e2e-test-db.json'; test('project file upload', async ({ page }) => { await page.goto('http://localhost:4001/editor'); @@ -46,7 +46,7 @@ test('project file download', async ({ page }) => { const downloadPromise = page.waitForEvent('download'); await page - .getByRole('row', { name: RegExp('^test-db') }) + .getByRole('row', { name: /^e2e-test-db/ }) .getByLabel('Options') .click(); await page.getByRole('menuitem', { name: 'Download' }).click(); diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json new file mode 100644 index 000000000..c30468f97 --- /dev/null +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -0,0 +1,519 @@ +{ + "rundowns": { + "demo": { + "id": "demo", + "title": "Eurovision Demo", + "order": [ + "32d31", + "21cd2", + "0b371", + "3cd28", + "e457f", + "01e85", + "1c420", + "b7737", + "d3a80", + "8276c", + "2340b", + "cb90b", + "503c4", + "5e965", + "bab4a", + "d3eb1" + ], + "entries": { + "32d31": { + "type": "event", + "id": "32d31", + "cue": "SF1.01", + "title": "Albania", + "note": "SF1.01", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 36000000, + "timeEnd": 37200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 0, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sekret", + "artist": "Ronela Hajati" + } + }, + "21cd2": { + "type": "event", + "id": "21cd2", + "cue": "SF1.02", + "title": "Latvia", + "note": "SF1.02", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 37500000, + "timeEnd": 38700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Eat Your Salad", + "artist": "Citi Zeni" + } + }, + "0b371": { + "type": "event", + "id": "0b371", + "cue": "SF1.03", + "title": "Lithuania", + "note": "SF1.03", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 39000000, + "timeEnd": 40200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Sentimentai", + "artist": "Monika Liu" + } + }, + "3cd28": { + "type": "event", + "id": "3cd28", + "cue": "SF1.04", + "title": "Switzerland", + "note": "SF1.04", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 40500000, + "timeEnd": 41700000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Boys Do Cry", + "artist": "Marius Bear" + } + }, + "e457f": { + "type": "event", + "id": "e457f", + "cue": "SF1.05", + "title": "Slovenia", + "note": "SF1.05", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 42000000, + "timeEnd": 43200000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Disko", + "artist": "LPS" + } + }, + "01e85": { + "type": "block", + "id": "01e85", + "title": "Lunch break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "1c420": { + "type": "event", + "id": "1c420", + "cue": "SF1.06", + "title": "Ukraine", + "note": "SF1.06", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 47100000, + "timeEnd": 48300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 3900000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Stefania", + "artist": "Kalush Orchestra" + } + }, + "b7737": { + "type": "event", + "id": "b7737", + "cue": "SF1.07", + "title": "Bulgaria", + "note": "SF1.07", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 48600000, + "timeEnd": 49800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Intention", + "artist": "Intelligent Music Project" + } + }, + "d3a80": { + "type": "event", + "id": "d3a80", + "cue": "SF1.08", + "title": "Netherlands", + "note": "SF1.08", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 50100000, + "timeEnd": 51300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "De Diepte", + "artist": "S10" + } + }, + "8276c": { + "type": "event", + "id": "8276c", + "cue": "SF1.09", + "title": "Moldova", + "note": "SF1.09", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 51600000, + "timeEnd": 52800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Trenuletul", + "artist": "Zdob si Zdub" + } + }, + "2340b": { + "type": "event", + "id": "2340b", + "cue": "SF1.10", + "title": "Portugal", + "note": "SF1.10", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 53100000, + "timeEnd": 54300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Saudade Saudade", + "artist": "Maro" + } + }, + "cb90b": { + "type": "block", + "id": "cb90b", + "title": "Afternoon break", + "note": "", + "colour": "", + "events": [], + "skip": false, + "custom": {}, + "revision": 0, + "startTime": null, + "endTime": null, + "duration": 0, + "isFirstLinked": false, + "numEvents": 0 + }, + "503c4": { + "type": "event", + "id": "503c4", + "cue": "SF1.11", + "title": "Croatia", + "note": "SF1.11", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 56100000, + "timeEnd": 57300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 1800000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Guilty Pleasure", + "artist": "Mia Dimsic" + } + }, + "5e965": { + "type": "event", + "id": "5e965", + "cue": "SF1.12", + "title": "Denmark", + "note": "SF1.12", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 57600000, + "timeEnd": 58800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "The Show", + "artist": "Reddi" + } + }, + "bab4a": { + "type": "event", + "id": "bab4a", + "cue": "SF1.13", + "title": "Austria", + "note": "SF1.13", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 59100000, + "timeEnd": 60300000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Halo", + "artist": "LUM!X & Pia Maria" + } + }, + "d3eb1": { + "type": "event", + "id": "d3eb1", + "cue": "SF1.14", + "title": "Greece", + "note": "SF1.14", + "endAction": "none", + "timerType": "count-down", + "countToEnd": false, + "linkStart": null, + "timeStrategy": "lock-end", + "timeStart": 60600000, + "timeEnd": 61800000, + "duration": 1200000, + "isPublic": true, + "skip": false, + "colour": "", + "currentBlock": null, + "revision": 0, + "delay": 0, + "dayOffset": 0, + "gap": 300000, + "timeWarning": 500000, + "timeDanger": 100000, + "custom": { + "song": "Die Together", + "artist": "Amanda Tenfjord" + } + } + }, + "revision": 0 + } + }, + "project": { + "title": "Eurovision Song Contest", + "description": "Turin 2022", + "publicUrl": "www.getontime.no", + "publicInfo": "Rehearsal Schedule - Turin 2022", + "backstageUrl": "www.github.com/cpvalente/ontime", + "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", + "projectLogo": null + }, + "settings": { + "app": "ontime", + "version": "-", + "serverPort": 4001, + "editorKey": null, + "operatorKey": null, + "timeFormat": "24", + "language": "en" + }, + "viewSettings": { + "dangerColor": "#ED3333", + "endMessage": "", + "freezeEnd": false, + "normalColor": "#ffffffcc", + "overrideStyles": false, + "warningColor": "#FFAB33" + }, + "customFields": { + "song": { + "label": "Song", + "type": "string", + "colour": "#339E4E" + }, + "artist": { + "label": "Artist", + "type": "string", + "colour": "#3E75E8" + } + }, + "urlPresets": [ + { + "enabled": true, + "alias": "test", + "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" + } + ], + "automation": { + "enabledAutomations": false, + "enabledOscIn": true, + "oscPortIn": 8888, + "triggers": [], + "automations": {} + } +} diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json deleted file mode 100644 index b2d559fbe..000000000 --- a/e2e/tests/fixtures/test-db.json +++ /dev/null @@ -1,459 +0,0 @@ -{ - "rundown": [ - { - "id": "32d31", - "type": "event", - "title": "Albania", - "timeStart": 36000000, - "timeEnd": 37200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.01", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.01", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sekret", - "artist": "Ronela Hajati" - } - }, - { - "id": "21cd2", - "type": "event", - "title": "Latvia", - "timeStart": 37500000, - "timeEnd": 38700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.02", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.02", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Eat Your Salad", - "artist": "Citi Zeni" - } - }, - { - "id": "0b371", - "type": "event", - "title": "Lithuania", - "timeStart": 39000000, - "timeEnd": 40200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.03", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.03", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Sentimentai", - "artist": "Monika Liu" - } - }, - { - "id": "3cd28", - "type": "event", - "title": "Switzerland", - "timeStart": 40500000, - "timeEnd": 41700000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.04", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.04", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Boys Do Cry", - "artist": "Marius Bear" - } - }, - { - "id": "e457f", - "type": "event", - "title": "Slovenia", - "timeStart": 42000000, - "timeEnd": 43200000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.05", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.05", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Disko", - "artist": "LPS" - } - }, - { - "title": "Lunch break", - "type": "block", - "id": "01e85" - }, - { - "id": "1c420", - "type": "event", - "title": "Ukraine", - "timeStart": 47100000, - "timeEnd": 48300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.06", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.06", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Stefania", - "artist": "Kalush Orchestra" - } - }, - { - "id": "b7737", - "type": "event", - "title": "Bulgaria", - "timeStart": 48600000, - "timeEnd": 49800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.07", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.07", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Intention", - "artist": "Intelligent Music Project" - } - }, - { - "id": "d3a80", - "type": "event", - "title": "Netherlands", - "timeStart": 50100000, - "timeEnd": 51300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.08", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.08", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "De Diepte", - "artist": "S10" - } - }, - { - "id": "8276c", - "type": "event", - "title": "Moldova", - "timeStart": 51600000, - "timeEnd": 52800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.09", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.09", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Trenuletul", - "artist": "Zdob si Zdub" - } - }, - { - "id": "2340b", - "type": "event", - "title": "Portugal", - "timeStart": 53100000, - "timeEnd": 54300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.10", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.10", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Saudade Saudade", - "artist": "Maro" - } - }, - { - "title": "Afternoon break", - "type": "block", - "id": "cb90b" - }, - { - "id": "503c4", - "type": "event", - "title": "Croatia", - "timeStart": 56100000, - "timeEnd": 57300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.11", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.11", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Guilty Pleasure", - "artist": "Mia Dimsic" - } - }, - { - "id": "5e965", - "type": "event", - "title": "Denmark", - "timeStart": 57600000, - "timeEnd": 58800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.12", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.12", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "The Show", - "artist": "Reddi" - } - }, - { - "id": "bab4a", - "type": "event", - "title": "Austria", - "timeStart": 59100000, - "timeEnd": 60300000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.13", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.13", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Halo", - "artist": "LUM!X & Pia Maria" - } - }, - { - "id": "d3eb1", - "type": "event", - "title": "Greece", - "timeStart": 60600000, - "timeEnd": 61800000, - "duration": 1200000, - "timeStrategy": "lock-duration", - "linkStart": null, - "endAction": "none", - "timerType": "count-down", - "countToEnd": false, - "isPublic": true, - "skip": false, - "note": "SF1.14", - "colour": "", - "delay": 0, - "dayOffset": 0, - "gap": 0, - "cue": "SF1.14", - "revision": 0, - "timeWarning": 120000, - "timeDanger": 60000, - "custom": { - "song": "Die Together", - "artist": "Amanda Tenfjord" - } - } -], - "project": { - "title": "Eurovision Song Contest", - "description": "Turin 2022", - "publicUrl": "www.getontime.no", - "publicInfo": "Rehearsal Schedule - Turin 2022", - "backstageUrl": "www.github.com/cpvalente/ontime", - "backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal", - "projectLogo": null, - "custom": [] - }, - "settings": { - "app": "ontime", - "version": "3.10.2", - "serverPort": 4001, - "editorKey": null, - "operatorKey": null, - "timeFormat": "24", - "language": "en" - }, - "viewSettings": { - "overrideStyles": false, - "normalColor": "#ffffffcc", - "warningColor": "#FFAB33", - "dangerColor": "#ED3333", - "endMessage": "", - "freezeEnd": false - }, - "urlPresets": [ - { - "enabled": true, - "alias": "test", - "pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5" - } - ], - "automation": { - "enabledAutomations": false, - "enabledOscIn": true, - "oscPortIn": 8888, - "triggers": [], - "automations": {} - }, - "customFields": { - "song": { - "type": "string", - "colour": "", - "label": "song" - }, - "artist": { - "type": "string", - "colour": "", - "label": "artist" - } - } -} diff --git a/package.json b/package.json index f1a7ba039..0f730c07c 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,11 @@ "dist-win": "turbo run dist-win", "dist-mac": "turbo run dist-mac", "dist-linux": "turbo run dist-linux", - "e2e": "cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts", + "e2e": "pnpm clear-temp && cross-env DEBUG=pw:webserver npx playwright test -c playwright.config.ts", "e2e:ui": "cross-env DEBUG=pw:webserver npx playwright test --ui -c playwright.config.ts", "e2e:i": "npx playwright codegen", - "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules" + "cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules", + "clear-temp": "rm -rf e2e/tests/fixtures/tmp" }, "devDependencies": { "@playwright/test": "^1.49.1", diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index f2d7538e9..cea3ff470 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -1,17 +1,8 @@ import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js'; -import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js'; - -type EventId = string; -export type NormalisedRundown = Record; - -export interface RundownCached { - rundown: NormalisedRundown; - order: EventId[]; - revision: number; -} +import type { OntimeEntry } from '../../definitions/core/Rundown.type.js'; export type PatchWithId = Partial & { id: string }; -export type EventPostPayload = Partial & { +export type EventPostPayload = Partial & { after?: string; before?: string; }; @@ -20,3 +11,10 @@ export type TransientEventPayload = Partial; -export type EventCustomFields = Record; +export type EntryCustomFields = Record; diff --git a/packages/types/src/definitions/core/OntimeEvent.type.ts b/packages/types/src/definitions/core/OntimeEvent.type.ts index 07b8362cd..5fd073071 100644 --- a/packages/types/src/definitions/core/OntimeEvent.type.ts +++ b/packages/types/src/definitions/core/OntimeEvent.type.ts @@ -1,4 +1,6 @@ -import type { EndAction, EventCustomFields, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js'; +import type { EndAction, EntryCustomFields, MaybeNumber, MaybeString, TimerType, TimeStrategy, Trigger } from '../../index.js'; + +export type EntryId = string; export enum SupportedEvent { Event = 'event', @@ -8,7 +10,7 @@ export enum SupportedEvent { export type OntimeBaseEvent = { type: SupportedEvent; - id: string; + id: EntryId; }; export type OntimeDelay = OntimeBaseEvent & { @@ -19,6 +21,18 @@ export type OntimeDelay = OntimeBaseEvent & { export type OntimeBlock = OntimeBaseEvent & { type: SupportedEvent.Block; title: string; + note: string; + events: EntryId[]; + skip: boolean; + colour: string; + custom: EntryCustomFields; + // !==== RUNTIME METADATA ====! // + revision: number; + startTime: MaybeNumber; // calculated at runtime + endTime: MaybeNumber; // calculated at runtime + duration: number; // calculated at runtime + isFirstLinked: boolean; // calculated at runtime, whether the first event is linked + numEvents: number; // calculated at runtime }; export type OntimeEvent = OntimeBaseEvent & { @@ -37,14 +51,16 @@ export type OntimeEvent = OntimeBaseEvent & { isPublic: boolean; skip: boolean; colour: string; + timeWarning: number; + timeDanger: number; + custom: EntryCustomFields; + triggers?: Trigger[]; + // !==== RUNTIME METADATA ====! // + currentBlock: EntryId | null; revision: number; delay: number; // calculated at runtime dayOffset: number; // calculated at runtime gap: number; // calculated at runtime - timeWarning: number; - timeDanger: number; - custom: EventCustomFields; - triggers?: Trigger[]; }; export type PlayableEvent = OntimeEvent & { skip: false }; diff --git a/packages/types/src/definitions/core/Rundown.type.ts b/packages/types/src/definitions/core/Rundown.type.ts index 63a08db70..69fd3268f 100644 --- a/packages/types/src/definitions/core/Rundown.type.ts +++ b/packages/types/src/definitions/core/Rundown.type.ts @@ -1,7 +1,17 @@ -import type { OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; +import type { EntryId, OntimeBlock, OntimeDelay, OntimeEvent } from './OntimeEvent.type.js'; -export type OntimeRundownEntry = OntimeDelay | OntimeBlock | OntimeEvent; -export type OntimeRundown = OntimeRundownEntry[]; +export type OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent; +export type RundownEntries = Record; // we need to create a manual union type since keys cannot be used in type unions export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock; + +export type ProjectRundowns = Record; + +export type Rundown = { + id: string; + title: string; + order: EntryId[]; + entries: RundownEntries; + revision: number; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7087d40f3..ce19a5d96 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,6 +4,7 @@ export type { DatabaseModel } from './definitions/DataModel.type.js'; // ---> Rundown export { EndAction } from './definitions/EndAction.type.js'; export { + type EntryId, type OntimeBaseEvent, type OntimeDelay, type OntimeBlock, @@ -12,7 +13,13 @@ export { type TimeField, SupportedEvent, } from './definitions/core/OntimeEvent.type.js'; -export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js'; +export type { + OntimeEntryCommonKeys, + OntimeEntry, + RundownEntries, + Rundown, + ProjectRundowns, +} from './definitions/core/Rundown.type.js'; export { TimeStrategy } from './definitions/TimeStrategy.type.js'; export { TimerType } from './definitions/TimerType.type.js'; @@ -53,7 +60,7 @@ export type { CustomFields, CustomField, CustomFieldLabel, - EventCustomFields, + EntryCustomFields, } from './definitions/core/CustomFields.type.js'; // SERVER RESPONSES @@ -73,9 +80,8 @@ export type { export type { QuickStartData } from './api/db/db.type.js'; export type { EventPostPayload, - NormalisedRundown, PatchWithId, - RundownCached, + ProjectRundownsList, TransientEventPayload, } from './api/rundown-controller/BackendResponse.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 552cd2465..4403f89b0 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,10 +1,10 @@ import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js'; import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; -import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; +import type { OntimeEntry } from '../definitions/core/Rundown.type.js'; import { type TimerLifeCycle, timerLifecycleValues } from '../definitions/core/TimerLifecycle.type.js'; -type MaybeEvent = OntimeRundownEntry | Partial | null | undefined; +type MaybeEvent = OntimeEntry | Partial | null | undefined; export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { return event?.type === SupportedEvent.Event; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 06ec95a5d..9ef4e100f 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -8,10 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { - filterPlayable, - filterTimedEvents, getEventWithId, - getFirst, getFirstEvent, getFirstEventNormal, getFirstNormal, @@ -66,7 +63,7 @@ export { deepmerge } from './src/externals/deepmerge.js'; // array utils export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js'; // object utils -export { getPropertyFromPath } from './src/common/objectUtils.js'; +export { getPropertyFromPath, isObjectEmpty } from './src/common/objectUtils.js'; // generic utilities export { getErrorMessage } from './src/generic/generic.js'; diff --git a/packages/utils/src/common/arrayUtils.ts b/packages/utils/src/common/arrayUtils.ts index 3209004ca..13b2701e4 100644 --- a/packages/utils/src/common/arrayUtils.ts +++ b/packages/utils/src/common/arrayUtils.ts @@ -36,11 +36,8 @@ export function deleteAtIndex(index: number, array: T[]) { /** * Reorders two objects in an array - * @param array - * @param fromIndex - * @param toIndex */ -export function reorderArray(array: T[], fromIndex: number, toIndex: number) { +export function reorderArray(array: T[], fromIndex: number, toIndex: number): T[] { if (fromIndex === toIndex) { return array; // No change needed, return the original array } diff --git a/packages/utils/src/common/objectUtils.ts b/packages/utils/src/common/objectUtils.ts index d9fe04e40..882c56cb0 100644 --- a/packages/utils/src/common/objectUtils.ts +++ b/packages/utils/src/common/objectUtils.ts @@ -16,3 +16,7 @@ export function getPropertyFromPath(path: string, obj: T): unk return result; } + +export function isObjectEmpty(obj: object): boolean { + return Object.keys(obj).length === 0; +} diff --git a/packages/utils/src/cue-utils/cueUtils.test.ts b/packages/utils/src/cue-utils/cueUtils.test.ts index 292ad30b6..03c8d221c 100644 --- a/packages/utils/src/cue-utils/cueUtils.test.ts +++ b/packages/utils/src/cue-utils/cueUtils.test.ts @@ -1,4 +1,4 @@ -import type { OntimeDelay, OntimeEvent, OntimeRundown } from 'ontime-types'; +import type { OntimeDelay, OntimeEntry, OntimeEvent, RundownEntries } from 'ontime-types'; import { SupportedEvent } from 'ontime-types'; import { getCueCandidate, getIncrement, sanitiseCue } from './cueUtils.js'; @@ -36,76 +36,83 @@ describe('getIncrement()', () => { describe('getCueCandidate()', () => { describe('in the beginning of the rundown', () => { it('names cue as 1 if next event does not collide', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event }, - { id: '2', cue: '11', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '11', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('1'); }); + it('creates decimal stem if next cue is 1', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('0.1'); }); }); + describe('in the middle of the rundown', () => { it('names cue as an increment if next event has different stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('2'); }); + it('names cue as an increment if next event has different stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter', type: SupportedEvent.Event }, - { + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Interval', type: SupportedEvent.Event, - }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + } as OntimeEntry, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter2'); }); + it('creates decimal stem if next cue has same stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('1.1'); }); + it('creates decimal stem if next cue has same stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter1', type: SupportedEvent.Event }, - { id: '2', cue: 'Presenter2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Presenter2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter1.1'); }); }); + describe('considers edge cases', () => { it('previousEvent might not be a cue', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, - { id: '2', type: SupportedEvent.Delay } as OntimeDelay, - ]; - const cue = getCueCandidate(testRundown, '2'); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + }; + const cue = getCueCandidate(entries, ['1', '2'], '2'); expect(cue).toBe('11'); }); }); + it('there might not be events before', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Delay } as OntimeDelay, - { id: '2', type: SupportedEvent.Delay } as OntimeDelay, - ]; - const cue = getCueCandidate(testRundown, '2'); + const entries: RundownEntries = { + '1': { id: '1', type: SupportedEvent.Delay } as OntimeDelay, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + }; + const cue = getCueCandidate(entries, ['1', '2'], '2'); expect(cue).toBe('1'); }); }); @@ -113,53 +120,58 @@ describe('getCueCandidate()', () => { describe('findCueName() with mixed events', () => { describe('in the beginning of the rundown', () => { it('names cue as 1 if next event does not collide', () => { - const testRundown = [ - { id: '1', cue: '10', type: SupportedEvent.Event }, - { id: '2', cue: '11', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '11', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('1'); }); + it('creates decimal stem if next cue is 1', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2']); expect(cue).toBe('0.1'); }); }); + describe('in the middle of the rundown', () => { it('names cue as an increment if next event has different stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '10', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '10', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('2'); }); + it('names cue as an increment if next event has different stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter', type: SupportedEvent.Event }, - { id: '2', cue: 'Interval', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Interval', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter2'); }); + it('creates decimal stem if next cue has same stem (case of numbers)', () => { - const testRundown = [ - { id: '1', cue: '1', type: SupportedEvent.Event }, - { id: '2', cue: '2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: '2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('1.1'); }); + it('creates decimal stem if next cue has same stem (case of letters)', () => { - const testRundown = [ - { id: '1', cue: 'Presenter1', type: SupportedEvent.Event }, - { id: '2', cue: 'Presenter2', type: SupportedEvent.Event }, - ] as OntimeRundown; - const cue = getCueCandidate(testRundown, '1'); + const entries: RundownEntries = { + '1': { id: '1', cue: 'Presenter1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', cue: 'Presenter2', type: SupportedEvent.Event } as OntimeEvent, + }; + const cue = getCueCandidate(entries, ['1', '2'], '1'); expect(cue).toBe('Presenter1.1'); }); }); diff --git a/packages/utils/src/cue-utils/cueUtils.ts b/packages/utils/src/cue-utils/cueUtils.ts index 7571bf424..632b14840 100644 --- a/packages/utils/src/cue-utils/cueUtils.ts +++ b/packages/utils/src/cue-utils/cueUtils.ts @@ -1,7 +1,7 @@ -import type { OntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types'; +import type { EntryId, OntimeEntry, RundownEntries } from 'ontime-types'; import { isOntimeEvent } from 'ontime-types'; -import { getFirstEvent, getNextEvent, getPreviousEvent } from '../rundown-utils/rundownUtils.js'; +import { getFirstEventNormal, getNextEventNormal, getPreviousEventNormal } from '../rundown-utils/rundownUtils.js'; import { isNumeric } from '../types/types.js'; // Zero or more non-digit characters at the beginning ((\D*)). @@ -11,7 +11,6 @@ const regex = /^(\D*)(\d+)(\.\d+)?$/; /** * Finds if last characters in input are a number and increments - * @param input {string} */ export function getIncrement(input: string): string { // Check if the input string contains a number at the end @@ -41,55 +40,57 @@ export function getIncrement(input: string): string { /** * Gets suitable name for a new event cue - * @param rundown {OntimeRundown} - * @param insertAfterId {string} */ -export function getCueCandidate(rundown: OntimeRundown, insertAfterId?: string): string { - function addAtTop() { - const firstEventCue = getFirstEvent(rundown).firstEvent?.cue; +export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId?: string): string { + // we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1 + if (insertAfterId === undefined || order.length === 0) { + return addAtTop(); + } - if (isNumeric(firstEventCue)) { - return (Number(firstEventCue) / 10).toString(); + // get the given event, or any before that + let previousEvent: OntimeEntry | null | undefined = entries[insertAfterId]; + + if (!isOntimeEvent(previousEvent)) { + previousEvent = getPreviousEventNormal(entries, order, insertAfterId).previousEvent; + if (!isOntimeEvent(previousEvent)) { + return addAtTop(); + } + } + + // the cue is based on the previous event cue + const cue = getIncrement(previousEvent.cue); + const { nextEvent } = getNextEventNormal(entries, order, insertAfterId); + + // if increment is clashing with next, we add a decimal instead + if (cue !== nextEvent?.cue) { + return cue; + } + + // there is a clash, bt the cue is a pure number + if (isNumeric(cue)) { + return incrementDecimal(previousEvent.cue); + } + + /** + * at this point, we know the cue is not numeric + * but the increment failed, so we have a numeric ending + * eg. Presenter 1 .... Presenter 2 -> Presenter1.1 + * eg. Presenter 1.1 .... Presenter 1.2 -> Presenter1.1.1 + */ + return `${previousEvent.cue}.1`; + + function incrementDecimal(cue: string) { + const n = Number(cue); + return (n + 0.1).toString(); + } + + function addAtTop() { + const firstEventCue = getFirstEventNormal(entries, order).firstEvent?.cue; + if (firstEventCue === '1') { + return '0.1'; } return '1'; } - - // we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1 - if (typeof insertAfterId === 'undefined' || rundown.length === 0) { - return addAtTop(); - } - - const afterIndex = rundown.findIndex((event) => event.id === insertAfterId); - - // we did not find the previous element, insert at top - if (afterIndex === -1) { - return addAtTop(); - } - - // get elements around - let previousEvent: OntimeRundownEntry | undefined | null | OntimeEvent = rundown.at(afterIndex); - if (!isOntimeEvent(previousEvent)) { - previousEvent = getPreviousEvent(rundown, insertAfterId).previousEvent as null | OntimeEvent; - } - - let cue = '1'; - const { nextEvent } = getNextEvent(rundown, insertAfterId); - - // try and increment the cue - if (isOntimeEvent(previousEvent)) { - cue = getIncrement(previousEvent.cue); - } - - // if increment is clashing with next, we add a decimal instead - if (cue === nextEvent?.cue) { - if (previousEvent === null) { - cue = '0.1'; - } else { - cue = `${previousEvent.cue}.1`; - } - } - - return cue; } export function sanitiseCue(cue: string) { diff --git a/packages/utils/src/date-utils/checkIsNextDay.test.ts b/packages/utils/src/date-utils/checkIsNextDay.test.ts index 9abbee852..235ba8f24 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.test.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.test.ts @@ -4,7 +4,7 @@ import { MILLIS_PER_HOUR } from './conversionUtils'; describe('checkIsNextDay', () => { it('returns false if there is no previous event', () => { const current = { timeStart: 0, dayOffset: 0 }; - const previous = undefined; + const previous = null; expect(checkIsNextDay(current, previous)).toBeFalsy(); }); diff --git a/packages/utils/src/date-utils/checkIsNextDay.ts b/packages/utils/src/date-utils/checkIsNextDay.ts index 71a629ba6..8b4476ae0 100644 --- a/packages/utils/src/date-utils/checkIsNextDay.ts +++ b/packages/utils/src/date-utils/checkIsNextDay.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function checkIsNextDay( current: Pick, - previous?: Pick, + previous: Pick | null, ): boolean { if (!previous) { return false; diff --git a/packages/utils/src/date-utils/getTimeFromPrevious.ts b/packages/utils/src/date-utils/getTimeFromPrevious.ts index f9227c18a..4f22b35d5 100644 --- a/packages/utils/src/date-utils/getTimeFromPrevious.ts +++ b/packages/utils/src/date-utils/getTimeFromPrevious.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function getTimeFromPrevious( current: Pick, - previous?: Pick, + previous: Pick | null, ): number { // there is no previous event if (!previous) { diff --git a/packages/utils/src/date-utils/isNewLatest.test.ts b/packages/utils/src/date-utils/isNewLatest.test.ts index 3dbef16b3..562915550 100644 --- a/packages/utils/src/date-utils/isNewLatest.test.ts +++ b/packages/utils/src/date-utils/isNewLatest.test.ts @@ -4,7 +4,7 @@ import { isNewLatest } from './isNewLatest'; describe('isNewLatest', () => { it('should be true if there is no previous', () => { const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 }; - const previous = undefined; + const previous = null; expect(isNewLatest(current, previous)).toBe(true); }); diff --git a/packages/utils/src/date-utils/isNewLatest.ts b/packages/utils/src/date-utils/isNewLatest.ts index 7bdcfa342..a5c6bcd7a 100644 --- a/packages/utils/src/date-utils/isNewLatest.ts +++ b/packages/utils/src/date-utils/isNewLatest.ts @@ -7,7 +7,7 @@ import { dayInMs } from './conversionUtils.js'; */ export function isNewLatest( currentEvent: Pick, - previousEvent?: Pick, + previousEvent: Pick | null, ) { // true if there is no previous if (!previousEvent) { diff --git a/packages/utils/src/rundown-utils/rundownUtils.test.ts b/packages/utils/src/rundown-utils/rundownUtils.test.ts index bafa7f52b..b25522130 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.test.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.test.ts @@ -1,8 +1,7 @@ -import type { NormalisedRundown, OntimeEvent, OntimeRundown } from 'ontime-types'; +import type { OntimeBlock, OntimeDelay, OntimeEntry, OntimeEvent } from 'ontime-types'; import { SupportedEvent } from 'ontime-types'; import { - filterPlayable, getLastEvent, getLastNormal, getNext, @@ -15,36 +14,46 @@ import { describe('getNext()', () => { it('returns the next event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNext(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); - it('alows other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '1'); + it('returns any type of OntimeEntry ', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; + + const { nextEvent, nextIndex } = getNext(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); + it('returns null if none found', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - ]; - - const { nextEvent, nextIndex } = getNext(testRundown as OntimeRundown, '3'); + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + const { nextEvent, nextIndex } = getNext(testRundown, '3'); expect(nextEvent).toBe(null); expect(nextIndex).toBe(null); }); @@ -53,35 +62,37 @@ describe('getNext()', () => { describe('getNextEvent()', () => { it('returns the next event of type event', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Event } as OntimeEvent, + { id: '3', type: SupportedEvent.Event } as OntimeEvent, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent?.id).toBe('2'); expect(nextIndex).toBe(1); }); + it('ignores other event types', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Block } as OntimeBlock, + { id: '4', type: SupportedEvent.Event } as OntimeEvent, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent?.id).toBe('4'); expect(nextIndex).toBe(3); }); + it('returns null if none found', () => { const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Block } as OntimeBlock, ]; - const { nextEvent, nextIndex } = getNextEvent(testRundown as OntimeRundown, '1'); + const { nextEvent, nextIndex } = getNextEvent(testRundown, '1'); expect(nextEvent).toBe(null); expect(nextIndex).toBe(null); }); @@ -89,36 +100,46 @@ describe('getNextEvent()', () => { describe('getPrevious()', () => { it('returns the previous event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; - const { entry, index } = getPrevious(testRundown as OntimeRundown, '3'); + const { entry, index } = getPrevious(testRundown, '3'); expect(entry?.id).toBe('2'); expect(index).toBe(1); }); + it('allow other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; - const { entry, index } = getPrevious(testRundown as OntimeRundown, '3'); + const { entry, index } = getPrevious(testRundown, '3'); expect(entry?.id).toBe('2'); expect(index).toBe(1); }); - it('returns null if none found', () => { - const testRundown = [ - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { entry, index } = getPrevious(testRundown as OntimeRundown, '2'); + it('returns null if none found', () => { + const testRundown = { + entries: { + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; + + const { entry, index } = getPrevious(testRundown, '2'); expect(entry).toBe(null); expect(index).toBe(null); }); @@ -126,36 +147,47 @@ describe('getPrevious()', () => { describe('getPreviousEvent()', () => { it('returns the previous event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Event }, - { id: '3', type: SupportedEvent.Event }, - ]; + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Event } as OntimeEvent, + '3': { id: '3', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3'], + }; - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '3'); + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '3'); expect(previousEvent?.id).toBe('2'); expect(previousIndex).toBe(1); }); - it('ignores other event types', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '4'); + it('ignores other event types', () => { + const testRundown = { + entries: { + '1': { id: '1', type: SupportedEvent.Event } as OntimeEvent, + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['1', '2', '3', '4'], + }; + + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '4'); expect(previousEvent?.id).toBe('1'); expect(previousIndex).toBe(0); }); - it('returns null if none found', () => { - const testRundown = [ - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Block }, - { id: '4', type: SupportedEvent.Event }, - ]; - const { previousEvent, previousIndex } = getPreviousEvent(testRundown as OntimeRundown, '2'); + it('returns null if none found', () => { + const testRundown = { + entries: { + '2': { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + '3': { id: '3', type: SupportedEvent.Block } as OntimeBlock, + '4': { id: '4', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['2', '3', '4'], + }; + + const { previousEvent, previousIndex } = getPreviousEvent(testRundown, '2'); expect(previousEvent).toBe(null); expect(previousIndex).toBe(null); }); @@ -170,6 +202,7 @@ describe('swapEventData', () => { timeEnd: 1, duration: 1, delay: 1, + revision: 3, } as OntimeEvent; const eventB = { id: '2', @@ -178,9 +211,10 @@ describe('swapEventData', () => { timeEnd: 2, duration: 2, delay: 2, + revision: 7, } as OntimeEvent; - const { newA, newB } = swapEventData(eventA, eventB); + const [newA, newB] = swapEventData(eventA, eventB); expect(newA).toMatchObject({ id: '1', @@ -189,6 +223,7 @@ describe('swapEventData', () => { timeEnd: 1, duration: 1, delay: 1, + revision: 4, }); expect(newB).toMatchObject({ id: '2', @@ -197,121 +232,119 @@ describe('swapEventData', () => { timeEnd: 2, duration: 2, delay: 2, + revision: 8, }); }); }); describe('getLastEvent', () => { it('returns the last event of type event', () => { - const testRundown = [ - { id: '1', type: SupportedEvent.Event }, - { id: '2', type: SupportedEvent.Delay }, - { id: '3', type: SupportedEvent.Event }, - { id: '4', type: SupportedEvent.Block }, + const testRundown: OntimeEntry[] = [ + { id: '1', type: SupportedEvent.Event } as OntimeEvent, + { id: '2', type: SupportedEvent.Delay } as OntimeDelay, + { id: '3', type: SupportedEvent.Event } as OntimeEvent, + { id: '4', type: SupportedEvent.Block } as OntimeBlock, ]; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + const { lastEvent } = getLastEvent(testRundown); expect(lastEvent?.id).toBe('3'); }); - it('handles rundowns with a single event', () => { - const testRundown = [{ id: '1', type: SupportedEvent.Event }]; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + it('handles rundowns with a single event', () => { + const testRundown: OntimeEntry[] = [{ id: '1', type: SupportedEvent.Event } as OntimeEvent]; + const { lastEvent } = getLastEvent(testRundown); expect(lastEvent?.id).toBe('1'); }); describe('getLastNormal', () => { it('returns the last entry', () => { - const testRundown = { - 4: { id: '4', type: SupportedEvent.Block }, - 1: { id: '1', type: SupportedEvent.Event }, - 3: { id: '3', type: SupportedEvent.Event }, - 2: { id: '2', type: SupportedEvent.Delay }, + const entries = { + 4: { id: '4', type: SupportedEvent.Block } as OntimeBlock, + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + 3: { id: '3', type: SupportedEvent.Event } as OntimeEvent, + 2: { id: '2', type: SupportedEvent.Delay } as OntimeDelay, }; const order = ['1', '2', '3', '4']; - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal(entries, order); expect(lastEntry?.id).toBe('4'); }); - it('handles rundowns with a single event', () => { - const testRundown = [{ id: '1', type: SupportedEvent.Event }]; - const { lastEvent } = getLastEvent(testRundown as OntimeRundown); + it('handles rundowns with a single event', () => { + const entries = { + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + }; + + const lastEvent = getLastNormal(entries, ['1']); expect(lastEvent?.id).toBe('1'); }); it('handles empty order', () => { const testRundown = { - 4: { id: '4', type: SupportedEvent.Block }, - 1: { id: '1', type: SupportedEvent.Event }, - 3: { id: '3', type: SupportedEvent.Event }, - 2: { id: '2', type: SupportedEvent.Delay }, + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + 2: { id: '2', type: SupportedEvent.Event } as OntimeEvent, }; - const order: string[] = []; - - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal(testRundown, []); expect(lastEntry).toBe(null); }); it('handles empty rundown', () => { - const testRundown = {}; - - const order = ['1', '2', '3', '4']; - - const lastEntry = getLastNormal(testRundown as unknown as NormalisedRundown, order); + const lastEntry = getLastNormal({}, ['1', '2', '3', '4']); expect(lastEntry).toBe(null); }); }); - describe('relevantBlock', () => { - const testRundown = [ - { id: 'a', type: SupportedEvent.Event }, - { id: 'b', type: SupportedEvent.Event }, - { id: 'c', type: SupportedEvent.Event }, - { id: 'd', type: SupportedEvent.Delay }, - { id: 'e', type: SupportedEvent.Block }, - { id: 'f', type: SupportedEvent.Event }, - { id: 'g', type: SupportedEvent.Block }, - { id: 'h', type: SupportedEvent.Event }, - ]; + describe('getPreviousBlock()', () => { + const testRundown = { + entries: { + a: { id: 'a', type: SupportedEvent.Event } as OntimeEvent, + b: { id: 'b', type: SupportedEvent.Event } as OntimeEvent, + c: { id: 'c', type: SupportedEvent.Event } as OntimeEvent, + d: { id: 'd', type: SupportedEvent.Delay } as OntimeDelay, + e: { id: 'e', type: SupportedEvent.Block } as OntimeBlock, + f: { id: 'f', type: SupportedEvent.Event } as OntimeEvent, + g: { id: 'g', type: SupportedEvent.Block } as OntimeBlock, + h: { id: 'h', type: SupportedEvent.Event } as OntimeEvent, + }, + order: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + }; - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'h'); - - expect(block?.id).toBe('g'); + test.each([ + ['h', 'g'], + ['f', 'e'], + ])('returns the relevant block', (id, expected) => { + const block = getPreviousBlock(testRundown, id); + expect(block?.id).toBe(expected); }); - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'f'); - expect(block?.id).toBe('e'); + it('returns null if there is no parent block relevant block', () => { + const block = getPreviousBlock(testRundown, 'a'); + expect(block).toBe(null); }); - it('returns the relevant block', () => { - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); - expect(block).toBeNull(); - }); it('also works on index 0', () => { - testRundown.unshift({ id: '0', type: SupportedEvent.Block }); - const block = getPreviousBlock(testRundown as unknown as OntimeRundown, 'a'); + testRundown.order.unshift('0'); + // @ts-expect-error -- we are adding an event to the rundown + testRundown.entries['0'] = { id: '0', type: SupportedEvent.Block } as OntimeBlock; + const block = getPreviousBlock(testRundown, 'a'); expect(block?.id).toBe('0'); }); - }); - describe('filterPlayable()', () => { - test('should return an array with only playable events', () => { - const eventA = { id: 'a', type: SupportedEvent.Event } as OntimeEvent; - const eventB = { id: 'b', skip: true, type: SupportedEvent.Event } as OntimeEvent; - const testRundown = [ - eventA, - eventB, - { id: 'c', type: SupportedEvent.Delay }, - { id: 'd', type: SupportedEvent.Block }, - ]; - - const result = filterPlayable(testRundown as unknown as OntimeRundown); - expect(result).toMatchObject([eventA]); + it('returns the parent block if nested event', () => { + const testRundown = { + entries: { + 1: { id: '1', type: SupportedEvent.Event } as OntimeEvent, + block: { id: 'block', type: SupportedEvent.Block, events: ['21', '22', '23'] } as OntimeBlock, + 21: { id: '21', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + 22: { id: '22', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + 23: { id: '23', type: SupportedEvent.Event, currentBlock: 'block' } as OntimeEvent, + }, + order: ['1', 'block'], + }; + const block = getPreviousBlock(testRundown, '21'); + expect(block?.id).toBe('block'); }); }); }); diff --git a/packages/utils/src/rundown-utils/rundownUtils.ts b/packages/utils/src/rundown-utils/rundownUtils.ts index 622db1aa5..df7f77f97 100644 --- a/packages/utils/src/rundown-utils/rundownUtils.ts +++ b/packages/utils/src/rundown-utils/rundownUtils.ts @@ -1,37 +1,31 @@ import type { - NormalisedRundown, + EntryId, OntimeBlock, + OntimeEntry, OntimeEvent, - OntimeRundown, - OntimeRundownEntry, PlayableEvent, + Rundown, + RundownEntries, } from 'ontime-types'; import { isOntimeBlock, isOntimeEvent, isPlayableEvent } from 'ontime-types'; -type IndexAndEntry = { entry: OntimeRundownEntry | null; index: number | null }; - -/** - * Gets first event in rundown, if it exists - */ -export function getFirst(rundown: OntimeRundown) { - return rundown.length ? rundown[0] : null; -} +type IndexAndEntry = { entry: OntimeEntry | null; index: number | null }; /** * Gets first event in a normalised rundown, if it exists - * @param rundown - * @param order - * @returns */ -export function getFirstNormal(rundown: NormalisedRundown, order: string[]) { - const firstId = order[0]; - return rundown[firstId] ?? null; +export function getFirstNormal(entries: RundownEntries, order: EntryId[]): OntimeEntry | null { + if (!order.length) { + return null; + } + const eventId = order[0]; + return entries[eventId] ?? null; } /** * Gets first scheduled event in rundown, if it exists */ -export function getFirstEvent(rundown: OntimeRundown): { +export function getFirstEvent(rundown: OntimeEntry[]): { firstEvent: PlayableEvent | null; firstIndex: number | null; } { @@ -48,8 +42,8 @@ export function getFirstEvent(rundown: OntimeRundown): { * Gets first scheduled event in a normalised rundown, if it exists */ export function getFirstEventNormal( - rundown: NormalisedRundown, - order: string[], + rundown: RundownEntries, + order: EntryId[], ): { firstEvent: PlayableEvent | null; firstIndex: number | null; @@ -67,18 +61,18 @@ export function getFirstEventNormal( /** * Gets last event in a normalised rundown, if it exists */ -export function getLastNormal(rundown: NormalisedRundown, order: string[]): OntimeRundownEntry | null { +export function getLastNormal(entries: RundownEntries, order: EntryId[]): OntimeEntry | null { const lastId = order.at(-1); if (lastId === undefined) { return null; } - return rundown[lastId] ?? null; + return entries[lastId] ?? null; } /** * Gets last scheduled event in rundown, if it exists */ -export function getLastEvent(rundown: OntimeRundown): { +export function getLastEvent(rundown: OntimeEntry[]): { lastEvent: PlayableEvent | null; lastIndex: number | null; } { @@ -87,7 +81,7 @@ export function getLastEvent(rundown: OntimeRundown): { } for (let i = rundown.length - 1; i >= 0; i--) { - const lastEvent = rundown.at(i); + const lastEvent = rundown[i]; if (isOntimeEvent(lastEvent) && isPlayableEvent(lastEvent)) { return { lastEvent, lastIndex: i }; } @@ -99,7 +93,7 @@ export function getLastEvent(rundown: OntimeRundown): { * Gets last scheduled event in a normalised rundown, if it exists */ export function getLastEventNormal( - rundown: NormalisedRundown, + rundown: RundownEntries, order: string[], ): { lastEvent: OntimeEvent | null; @@ -123,13 +117,14 @@ export function getLastEventNormal( * Gets next entry in rundown, if it exists */ export function getNext( - rundown: OntimeRundown, + rundown: Pick, currentId: string, -): { nextEvent: OntimeRundownEntry | null; nextIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); - if (index !== -1 && index + 1 < rundown.length) { +): { nextEvent: OntimeEntry | null; nextIndex: number | null } { + const index = rundown.order.findIndex((entryId) => entryId === currentId); + if (index !== -1 && index + 1 < rundown.order.length) { const nextIndex = index + 1; - const nextEvent = rundown[nextIndex]; + const nextId = rundown.order[nextIndex]; + const nextEvent = rundown.entries[nextId]; return { nextEvent, nextIndex }; } else { return { nextEvent: null, nextIndex: null }; @@ -139,7 +134,7 @@ export function getNext( /** * Gets next entry in rundown, if it exists */ -export function getNextNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getNextNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); if (currentIndex !== -1 && currentIndex + 1 < order.length) { const index = currentIndex + 1; @@ -155,10 +150,10 @@ export function getNextNormal(rundown: NormalisedRundown, order: string[], curre * Gets next scheduled event in rundown, if it exists */ export function getNextEvent( - rundown: OntimeRundown, + rundown: OntimeEntry[], currentId: string, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); + const index = rundown.findIndex((entry) => entry.id === currentId); if (index < 0) { return { nextEvent: null, nextIndex: null }; } @@ -176,18 +171,18 @@ export function getNextEvent( * Gets next scheduled event in a normalised rundown, if it exists */ export function getNextEventNormal( - rundown: NormalisedRundown, - order: string[], + entries: RundownEntries, + order: EntryId[], currentId: string, ): { nextEvent: OntimeEvent | null; nextIndex: number | null } { - const index = order.findIndex((id) => id === currentId); + const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { nextEvent: null, nextIndex: null }; } for (let i = index + 1; i < order.length; i++) { const nextId = order[i]; - const nextEvent = rundown[nextId]; + const nextEvent = entries[nextId]; if (isOntimeEvent(nextEvent)) { return { nextEvent, nextIndex: i }; } @@ -198,11 +193,13 @@ export function getNextEventNormal( /** * Gets previous entry in rundown, if it exists */ -export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAndEntry { - const currentIndex = rundown.findIndex((event) => event.id === currentId); - if (currentIndex !== -1 && currentIndex - 1 >= 0) { +export function getPrevious(rundown: Pick, currentId: string): IndexAndEntry { + const currentIndex = rundown.order.findIndex((entryId) => entryId === currentId); + + if (currentIndex > 1) { const index = currentIndex - 1; - const entry = rundown[index]; + const previousId = rundown.order[index]; + const entry = rundown.entries[previousId]; return { entry, index }; } else { return { entry: null, index: null }; @@ -210,14 +207,15 @@ export function getPrevious(rundown: OntimeRundown, currentId: string): IndexAnd } /** - * Gets previous entry in a nornalised rundown, if it exists + * Gets previous entry in a normalised rundown, if it exists */ -export function getPreviousNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getPreviousNormal(entries: RundownEntries, order: string[], currentId: string): IndexAndEntry { const currentIndex = order.findIndex((id) => id === currentId); + if (currentIndex !== -1 && currentIndex - 1 >= 0) { const index = currentIndex - 1; const previousId = order[index]; - const entry = rundown[previousId]; + const entry = entries[previousId]; return { entry, index }; } else { return { entry: null, index: null }; @@ -228,15 +226,16 @@ export function getPreviousNormal(rundown: NormalisedRundown, order: string[], c * Gets previous scheduled event in rundown, if it exists */ export function getPreviousEvent( - rundown: OntimeRundown, + rundown: Pick, currentId: string, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { - const index = rundown.findIndex((event) => event.id === currentId); + const index = rundown.order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { previousEvent: null, previousIndex: null }; } for (let i = index - 1; i >= 0; i--) { - const previousEvent = rundown[i]; + const previousId = rundown.order[i]; + const previousEvent = rundown.entries[previousId]; if (isOntimeEvent(previousEvent)) { return { previousEvent, previousIndex: i }; } @@ -246,23 +245,19 @@ export function getPreviousEvent( /** * Gets previous scheduled event in a normalised rundown, if it exists - * @param rundown - * @param order - * @param {string} currentId - * @return {{ previousEvent: OntimeRundownEntry | null; previousIndex: number | null } } */ export function getPreviousEventNormal( - rundown: NormalisedRundown, - order: string[], + entries: RundownEntries, + order: EntryId[], currentId: string, ): { previousEvent: OntimeEvent | null; previousIndex: number | null } { - const index = order.findIndex((id) => id === currentId); + const index = order.findIndex((entryId) => entryId === currentId); if (index < 0) { return { previousEvent: null, previousIndex: null }; } for (let i = index - 1; i >= 0; i--) { const previousId = order[i]; - const previousEvent = rundown[previousId]; + const previousEvent = entries[previousId]; if (isOntimeEvent(previousEvent)) { return { previousEvent, previousIndex: i }; } @@ -273,7 +268,7 @@ export function getPreviousEventNormal( /** * @description swaps two OntimeEvents in the rundown */ -export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: OntimeEvent; newB: OntimeEvent } => { +export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): [newA: OntimeEvent, newB: OntimeEvent] => { const newA = { ...eventB, // events keep the ID @@ -304,17 +299,17 @@ export const swapEventData = (eventA: OntimeEvent, eventB: OntimeEvent): { newA: dayOffset: eventB.dayOffset, }; - return { newA, newB }; + return [newA, newB]; }; -export function getEventWithId(rundown: OntimeRundown, id: string): OntimeRundownEntry | undefined { +export function getEventWithId(rundown: OntimeEntry[], id: string): OntimeEntry | undefined { return rundown.find((event) => event.id === id); } /** * Gets relevant block element for a given ID */ -export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getPreviousBlockNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event for (let index = order.length - 1; index >= 0; index--) { @@ -337,7 +332,7 @@ export function getPreviousBlockNormal(rundown: NormalisedRundown, order: string /** * Gets next block element for a given ID */ -export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], currentId: string): IndexAndEntry { +export function getNextBlockNormal(rundown: RundownEntries, order: string[], currentId: string): IndexAndEntry { let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event for (let index = 0; index < order.length; index++) { @@ -360,11 +355,19 @@ export function getNextBlockNormal(rundown: NormalisedRundown, order: string[], /** * Gets relevant block element for a given ID */ -export function getPreviousBlock(rundown: OntimeRundown, currentId: string): OntimeBlock | null { +export function getPreviousBlock(rundown: Pick, currentId: EntryId): OntimeBlock | null { + const currentEvent = rundown.entries[currentId]; + + // check if event is inside a block + if (isOntimeEvent(currentEvent) && currentEvent.currentBlock) { + return rundown.entries[currentEvent.currentBlock] as OntimeBlock; + } + let foundCurrentEvent = false; // Iterate backwards through the rundown to find the current event - for (let i = rundown.length - 1; i >= 0; i--) { - const entry = rundown[i]; + for (let i = rundown.order.length - 1; i >= 0; i--) { + const entryId = rundown.order[i]; + const entry = rundown.entries[entryId]; if (!foundCurrentEvent && entry.id === currentId) { // set the flag when the current event is found foundCurrentEvent = true; @@ -375,20 +378,6 @@ export function getPreviousBlock(rundown: OntimeRundown, currentId: string): Ont return entry; } } - // no blocks exist before current event + // no blocks exist before null event return null; } - -/** - * filters a rundown to timed events - */ -export function filterPlayable(rundown: OntimeRundown): PlayableEvent[] { - return rundown.filter((event) => isOntimeEvent(event) && !event.skip) as PlayableEvent[]; -} - -/** - * filters a rundown to events that can be played - */ -export function filterTimedEvents(rundown: OntimeRundown): OntimeEvent[] { - return rundown.filter((event) => isOntimeEvent(event)) as OntimeEvent[]; -} diff --git a/playwright.config.ts b/playwright.config.ts index 547c7ca32..d6fac572e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ const config: PlaywrightTestConfig = { workers: 1, reporter: 'html', webServer: { - command: 'turbo run dev:test', + command: 'turbo run dev --filter=ontime-server', port: 4001, reuseExistingServer: true, timeout: 60 * 1000,