refactor: restructure model to contain an object of rundowns

This commit is contained in:
Carlos Valente
2025-03-15 09:04:33 +01:00
parent 433e9f6a4f
commit dbf8eeb7fb
111 changed files with 4366 additions and 4612 deletions
+5 -3
View File
@@ -2,7 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types'; import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { makeTable } from '../../views/cuesheet/cuesheet.utils'; import { makeTable } from '../../views/cuesheet/cuesheet.utils';
import { makeCSVFromArrayOfArrays } from '../utils/csv'; import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../utils/csv';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
import { createBlob, downloadBlob } from './utils'; import { createBlob, downloadBlob } from './utils';
@@ -40,9 +40,11 @@ export async function downloadProject(fileName: string) {
export async function downloadCSV(fileName: string = 'rundown') { export async function downloadCSV(fileName: string = 'rundown') {
try { try {
const { data, name } = await fileDownload(fileName); 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 fileContent = makeCSVFromArrayOfArrays(sheetData);
const blob = createBlob(fileContent, 'text/csv;charset=utf-8;'); const blob = createBlob(fileContent, 'text/csv;charset=utf-8;');
+5 -6
View File
@@ -1,16 +1,11 @@
import axios, { AxiosResponse } from 'axios'; import axios, { AxiosResponse } from 'axios';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import { ImportMap } from 'ontime-utils'; import { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
const excelPath = `${apiEntryUrl}/excel`; const excelPath = `${apiEntryUrl}/excel`;
type PreviewSpreadsheetResponse = {
rundown: OntimeRundown;
customFields: CustomFields;
};
/** /**
* upload Excel file to server * upload Excel file to server
* @return string - file ID op the uploaded file * @return string - file ID op the uploaded file
@@ -34,6 +29,10 @@ export async function getWorksheetNames(): Promise<string[]> {
return response.data; return response.data;
} }
type PreviewSpreadsheetResponse = {
rundown: Rundown;
customFields: CustomFields;
};
export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> { export async function importRundownPreview(options: ImportMap): Promise<PreviewSpreadsheetResponse> {
const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, { const response: AxiosResponse<PreviewSpreadsheetResponse> = await axios.post(`${excelPath}/preview`, {
options, options,
+21 -6
View File
@@ -1,29 +1,44 @@
import axios, { AxiosResponse } from 'axios'; 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'; import { apiEntryUrl } from './constants';
const rundownPath = `${apiEntryUrl}/rundown`; const rundownPath = `${apiEntryUrl}/rundown`;
/**
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(`${rundownPath}/`);
return res.data;
}
/** /**
* HTTP request to fetch all events * HTTP request to fetch all events
*/ */
export async function fetchNormalisedRundown(): Promise<RundownCached> { export async function fetchCurrentRundown(): Promise<Rundown> {
const res = await axios.get(`${rundownPath}/normalised`); const res = await axios.get(`${rundownPath}/current`);
return res.data; return res.data;
} }
/** /**
* HTTP request to post new event * HTTP request to post new event
*/ */
export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPostEvent(data: TransientEventPayload): Promise<AxiosResponse<OntimeEntry>> {
return axios.post(rundownPath, data); return axios.post(rundownPath, data);
} }
/** /**
* HTTP request to put new event * HTTP request to put new event
*/ */
export async function requestPutEvent(data: Partial<OntimeRundownEntry>): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestPutEvent(data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(rundownPath, data); return axios.put(rundownPath, data);
} }
@@ -48,7 +63,7 @@ export type ReorderEntry = {
/** /**
* HTTP request to reorder events * HTTP request to reorder events
*/ */
export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeRundownEntry>> { export async function requestReorderEvent(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data); return axios.patch(`${rundownPath}/reorder`, data);
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import axios, { AxiosResponse } from 'axios'; 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 { ImportMap } from 'ontime-utils';
import { apiEntryUrl } from './constants'; import { apiEntryUrl } from './constants';
@@ -54,7 +54,7 @@ export const previewRundown = async (
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
}> => { }> => {
const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options }); const response = await axios.post(`${sheetsPath}/${sheetId}/read`, { options });
@@ -1,23 +1,29 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { fetchNormalisedRundown } from '../api/rundown'; import { fetchCurrentRundown } from '../api/rundown';
import useProjectData from './useProjectData'; import useProjectData from './useProjectData';
// revision is -1 so that the remote revision is higher // 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 * Normalised rundown data
*/ */
export default function useRundown() { export default function useRundown() {
const { data, status, isError, refetch, isFetching } = useQuery<RundownCached>({ const { data, status, isError, refetch, isFetching } = useQuery<Rundown>({
queryKey: RUNDOWN, queryKey: RUNDOWN,
queryFn: fetchNormalisedRundown, queryFn: fetchCurrentRundown,
placeholderData: (previousData, _previousQuery) => previousData, placeholderData: (previousData, _previousQuery) => previousData,
retry: 5, retry: 5,
retryDelay: (attempt) => attempt * 2500, retryDelay: (attempt) => attempt * 2500,
@@ -37,16 +43,16 @@ export function useFlatRundown() {
const loadedProject = useRef<string>(''); const loadedProject = useRef<string>('');
const [prevRevision, setPrevRevision] = useState<number>(-1); const [prevRevision, setPrevRevision] = useState<number>(-1);
const [flatRunDown, setFlatRunDown] = useState<OntimeRundown>([]); const [flatRundown, setFlatRundown] = useState<OntimeEntry[]>([]);
// update data whenever the revision changes // update data whenever the revision changes
useEffect(() => { useEffect(() => {
if (data.revision !== -1 && data.revision !== prevRevision) { if (data.revision !== -1 && data.revision !== prevRevision) {
const flatRundown = data.order.map((id) => data.rundown[id]); const flatRundown = data.order.map((id) => data.entries[id]);
setFlatRunDown(flatRundown); setFlatRundown(flatRundown);
setPrevRevision(data.revision); 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? // TODO: should we have a project id field?
// invalidate current version if project changes // invalidate current version if project changes
@@ -57,13 +63,13 @@ export function useFlatRundown() {
} }
}, [projectData]); }, [projectData]);
return { data: flatRunDown, status }; return { data: flatRundown, status };
} }
/** /**
* Provides access to a partial rundown based on a filter callback * 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 { data, status } = useFlatRundown();
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return data.filter(cb); return data.filter(cb);
+76 -45
View File
@@ -2,11 +2,12 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
isOntimeEvent, isOntimeEvent,
MaybeString,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry, Rundown,
RundownCached,
TimeField, TimeField,
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
@@ -31,12 +32,12 @@ import { useEditorSettings } from '../stores/editorSettings';
export type EventOptions = Partial<{ export type EventOptions = Partial<{
// options to any new block (event / delay / block) // options to any new block (event / delay / block)
after: string; after: MaybeString;
before: string; before: MaybeString;
// options to blocks of type OntimeEvent // options to blocks of type OntimeEvent
defaultPublic: boolean; defaultPublic: boolean;
linkPrevious: boolean; linkPrevious: boolean;
lastEventId: string; lastEventId: MaybeString;
}>; }>;
/** /**
@@ -57,11 +58,11 @@ export const useEventAction = () => {
const getEventById = useCallback( const getEventById = useCallback(
(eventId: string) => { (eventId: string) => {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.rundown) { if (!cachedRundown?.entries) {
return; return;
} }
return cachedRundown.rundown[eventId]; return cachedRundown.entries[eventId];
}, },
[queryClient], [queryClient],
); );
@@ -100,9 +101,8 @@ export const useEventAction = () => {
newEvent.linkStart = applicationOptions.lastEventId; newEvent.linkStart = applicationOptions.lastEventId;
} else if (applicationOptions?.lastEventId) { } else if (applicationOptions?.lastEventId) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know this is a value
const rundownData = queryClient.getQueryData<RundownCached>(RUNDOWN)!; const rundownData = queryClient.getQueryData<Rundown>(RUNDOWN)!;
const { rundown } = rundownData; const previousEvent = rundownData.entries[applicationOptions.lastEventId];
const previousEvent = rundown[applicationOptions.lastEventId];
if (isOntimeEvent(previousEvent)) { if (isOntimeEvent(previousEvent)) {
newEvent.timeStart = previousEvent.timeEnd; newEvent.timeStart = previousEvent.timeEnd;
} }
@@ -180,15 +180,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
const eventId = newEvent.id; const eventId = newEvent.id;
if (previousData && eventId) { if (previousData && eventId) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
// @ts-expect-error -- we expect the events to be of same type // @ts-expect-error -- we expect the events to be of same type
newRundown[eventId] = { ...newRundown[eventId], ...newEvent }; newRundown[eventId] = { ...newRundown[eventId], ...newEvent };
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -196,7 +202,7 @@ export const useEventAction = () => {
}, },
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -210,7 +216,7 @@ export const useEventAction = () => {
* Updates existing event * Updates existing event
*/ */
const updateEvent = useCallback( const updateEvent = useCallback(
async (event: Partial<OntimeRundownEntry>) => { async (event: Partial<OntimeEntry>) => {
try { try {
await _updateEventMutation.mutateAsync(event); await _updateEventMutation.mutateAsync(event);
} catch (error) { } catch (error) {
@@ -296,9 +302,9 @@ export const useEventAction = () => {
* Utility function to get the previous event end time * Utility function to get the previous event end time
*/ */
function getPreviousEnd(): number { function getPreviousEnd(): number {
const cachedRundown = queryClient.getQueryData<RundownCached>(RUNDOWN); const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order || !cachedRundown?.rundown) { if (!cachedRundown?.order || !cachedRundown?.entries) {
return 0; return 0;
} }
@@ -308,7 +314,7 @@ export const useEventAction = () => {
} }
let previousEnd = 0; let previousEnd = 0;
for (let i = index - 1; i >= 0; i--) { 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)) { if (isOntimeEvent(event)) {
previousEnd = event.timeEnd; previousEnd = event.timeEnd;
break; break;
@@ -331,11 +337,11 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousEvents = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousEvents) { if (previousRundown) {
const eventIds = new Set(ids); const eventIds = new Set(ids);
const newRundown = { ...previousEvents.rundown }; const newRundown = { ...previousRundown.entries };
eventIds.forEach((eventId) => { eventIds.forEach((eventId) => {
if (Object.hasOwn(newRundown, 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>(RUNDOWN, {
id: previousRundown.id,
title: previousRundown.title,
order: previousRundown.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous rundown
return { previousEvents }; return { previousRundown };
}, },
onSettled: async () => { onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN }); await queryClient.invalidateQueries({ queryKey: RUNDOWN });
}, },
onError: (_error, _newEvent, context) => { onError: (_error, _newEvent, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousEvents); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
}, },
networkMode: 'always', networkMode: 'always',
}); });
@@ -386,19 +398,21 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = previousData.order.filter((id) => !eventIds.includes(id)); const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
for (const eventId of eventIds) { for (const eventId of eventIds) {
delete newRundown[eventId]; delete newRundown[eventId];
} }
queryClient.setQueryData(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder, order: newOrder,
rundown: newRundown, entries: newRundown,
revision: -1, revision: -1,
}); });
} }
@@ -409,7 +423,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -445,10 +459,16 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
// optimistically update object // optimistically update object
queryClient.setQueryData(RUNDOWN, { rundown: {}, order: [], revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default',
title: previousData?.title ?? '',
entries: {},
order: [],
revision: -1,
});
// Return a context with the previous and new events // Return a context with the previous and new events
return { previousData }; return { previousData };
@@ -456,7 +476,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undos optimist update // Mutation fails, rollback undos optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -516,13 +536,18 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to); const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
queryClient.setQueryData(RUNDOWN, { order: newOrder, rundown: previousData.rundown, revision: -1 }); id: previousData.id,
title: previousData.title,
order: newOrder,
entries: previousData.entries,
revision: -1,
});
} }
// Return a context with the previous and new events // Return a context with the previous and new events
@@ -531,7 +556,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -572,22 +597,28 @@ export const useEventAction = () => {
await queryClient.cancelQueries({ queryKey: RUNDOWN }); await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value // Snapshot the previous value
const previousData = queryClient.getQueryData<RundownCached>(RUNDOWN); const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newRundown = { ...previousData.rundown }; const newRundown = { ...previousData.entries };
const eventA = previousData.rundown[from]; const eventA = previousData.entries[from];
const eventB = previousData.rundown[to]; const eventB = previousData.entries[to];
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
return; return;
} }
const { newA, newB } = swapEventData(eventA, eventB); const [newA, newB] = swapEventData(eventA, eventB);
newRundown[from] = newA; newRundown[from] = newA;
newRundown[to] = newB; newRundown[to] = newB;
queryClient.setQueryData(RUNDOWN, { order: previousData.order, rundown: newRundown, revision: -1 }); queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: previousData.order,
entries: newRundown,
revision: -1,
});
} }
// Return a context with the previous events // Return a context with the previous events
@@ -596,7 +627,7 @@ export const useEventAction = () => {
// Mutation fails, rollback undoes optimist update // Mutation fails, rollback undoes optimist update
onError: (_error, _eventId, context) => { onError: (_error, _eventId, context) => {
queryClient.setQueryData(RUNDOWN, context?.previousData); queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
}, },
// Mutation finished, failed or successful // Mutation finished, failed or successful
// Fetch anyway, just to be sure // Fetch anyway, just to be sure
@@ -1,4 +1,6 @@
import { makeCSVFromArrayOfArrays } from '../csv'; import { OntimeEntry, ProjectRundowns, Rundown } from 'ontime-types';
import { aggregateRundowns, makeCSVFromArrayOfArrays } from '../csv';
describe('makeCSVFromArrayOfArrays()', () => { describe('makeCSVFromArrayOfArrays()', () => {
it('joins an array of arrays with commas and newlines', () => { 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' }]);
});
});
@@ -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'; import { cloneEvent } from '../eventsManager';
@@ -29,7 +29,7 @@ describe('cloneEvent()', () => {
gap: 0, gap: 0,
custom: { custom: {
lighting: '3', lighting: '3',
} as EventCustomFields, } as EntryCustomFields,
}; };
const cloned = cloneEvent(original); const cloned = cloneEvent(original);
@@ -64,13 +64,13 @@ describe('getRouteFromPreset()', () => {
describe('handle url sharing edge cases', () => { describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => { it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123'); 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', () => { it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123'); 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', () => { 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()', () => { describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => { it('checks whether the paths match', () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy(); expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy(); expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).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=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy(); 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=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy(); expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
}) });
}); });
+24 -3
View File
@@ -1,10 +1,31 @@
import { stringify } from 'csv-stringify/browser/esm/sync'; import { stringify } from 'csv-stringify/browser/esm/sync';
import { OntimeEntry, ProjectRundowns } from 'ontime-types';
/** /**
* @description Converts an array of arrays to a CSV file * Converts an array of arrays to a CSV file
* @param {string[][]} arrayOfArrays
* @return {string}
*/ */
export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string { export function makeCSVFromArrayOfArrays(arrayOfArrays: string[][]): string {
return stringify(arrayOfArrays); 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;
}
@@ -23,6 +23,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
isPublic: event.isPublic, isPublic: event.isPublic,
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
currentBlock: event.currentBlock,
revision: 0, revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset, dayOffset: event.dayOffset,
+2 -2
View File
@@ -1,4 +1,4 @@
import { Log, RundownCached, RuntimeStore } from 'ontime-types'; import { Log, Rundown, RuntimeStore } from 'ontime-types';
import { isProduction, websocketUrl } from '../../externals'; import { isProduction, websocketUrl } from '../../externals';
import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants';
@@ -201,7 +201,7 @@ export const connectSocket = () => {
invalidateAllCaches(); invalidateAllCaches();
} else if (target === 'RUNDOWN') { } else if (target === 'RUNDOWN') {
const { revision } = payload; const { revision } = payload;
const currentRevision = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN)?.revision ?? -1; const currentRevision = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN)?.revision ?? -1;
if (revision > currentRevision) { if (revision > currentRevision) {
ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN });
ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS });
@@ -29,8 +29,8 @@ export default function ReportSettings() {
}; };
const combinedReport = useMemo(() => { const combinedReport = useMemo(() => {
return getCombinedReport(reportData, data.rundown, data.order); return getCombinedReport(reportData, data.entries, data.order);
}, [reportData, data.rundown, data.order]); }, [reportData, data.entries, data.order]);
return ( return (
<Panel.Section> <Panel.Section>
@@ -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 { makeCSVFromArrayOfArrays } from '../../../../common/utils/csv';
import { formatTime } from '../../../../common/utils/time'; import { formatTime } from '../../../../common/utils/time';
@@ -16,7 +16,7 @@ export type CombinedReport = {
/** /**
* Creates a combined report with the rundown data * 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 (Object.keys(report).length === 0) return [];
if (order.length === 0) return []; if (order.length === 0) return [];
@@ -6,7 +6,7 @@ export async function makeProjectPatch(data: DatabaseModel, mergeKeys: Record<st
for (const key in mergeKeys) { for (const key in mergeKeys) {
if (isKeyOfType(key, data) && mergeKeys[key]) { if (isKeyOfType(key, data) && mergeKeys[key]) {
// if the rundown is merged we also need the custom fields // if the rundown is merged we also need the custom fields
if (key === 'rundown') { if (key === 'rundowns') {
patchObject.customFields = data['customFields']; patchObject.customFields = data['customFields'];
} }
Object.assign(patchObject, { [key]: data[key] }); Object.assign(patchObject, { [key]: data[key] });
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
@@ -9,7 +9,7 @@ import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore'; import { useSheetStore } from './useSheetStore';
interface ImportReviewProps { interface ImportReviewProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
onFinished: () => void; onFinished: () => void;
onCancel: () => void; onCancel: () => void;
@@ -29,7 +29,12 @@ export default function ImportReview(props: ImportReviewProps) {
const applyImport = async () => { const applyImport = async () => {
setLoading(true); setLoading(true);
await importRundown(rundown, customFields); await importRundown(
{
[rundown.id]: rundown,
},
customFields,
);
setLoading(false); setLoading(false);
onFinished(); onFinished();
}; };
@@ -1,6 +1,6 @@
import { Fragment } from 'react'; import { Fragment } from 'react';
import { IoLink } from 'react-icons/io5'; 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 { millisToString } from 'ontime-utils';
import Tag from '../../../../../common/components/tag/Tag'; import Tag from '../../../../../common/components/tag/Tag';
@@ -10,7 +10,7 @@ import * as Panel from '../../../panel-utils/PanelUtils';
import style from './PreviewRundown.module.scss'; import style from './PreviewRundown.module.scss';
interface PreviewRundownProps { interface PreviewRundownProps {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
@@ -53,75 +53,76 @@ export default function PreviewRundown(props: PreviewRundownProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rundown.map((event) => { {rundown.order.map((entryId) => {
if (isOntimeBlock(event)) { const entry = rundown.entries[entryId];
if (isOntimeBlock(entry)) {
return ( return (
<tr key={event.id}> <tr key={entry.id}>
<td className={style.center}> <td className={style.center}>
<Tag>-</Tag> <Tag>-</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td /> <td />
<td colSpan={99}>{event.title}</td> <td colSpan={99}>{entry.title}</td>
</tr> </tr>
); );
} }
if (!isOntimeEvent(event)) { if (!isOntimeEvent(entry)) {
return null; return null;
} }
eventIndex += 1; eventIndex += 1;
const colour = event.colour ? getAccessibleColour(event.colour) : {}; const colour = entry.colour ? getAccessibleColour(entry.colour) : {};
const countToEnd = booleanToText(event.countToEnd); const countToEnd = booleanToText(entry.countToEnd);
const isPublic = booleanToText(event.isPublic); const isPublic = booleanToText(entry.isPublic);
const skip = booleanToText(event.skip); const skip = booleanToText(entry.skip);
return ( return (
<Fragment key={event.id}> <Fragment key={entry.id}>
<tr> <tr>
<td className={style.center}> <td className={style.center}>
<Tag>{eventIndex}</Tag> <Tag>{eventIndex}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.type}</Tag> <Tag>{entry.type}</Tag>
</td> </td>
<td className={style.nowrap}>{event.cue}</td> <td className={style.nowrap}>{entry.cue}</td>
<td>{event.title}</td> <td>{entry.title}</td>
<td className={style.flex}> <td className={style.flex}>
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span> <span className={entry.linkStart ? style.subdued : undefined}>{millisToString(entry.timeStart)}</span>
{event.linkStart && <IoLink className={style.linkStartActive} />} {entry.linkStart && <IoLink className={style.linkStartActive} />}
</td> </td>
<td>{millisToString(event.timeEnd)}</td> <td>{millisToString(entry.timeEnd)}</td>
<td>{millisToString(event.duration)}</td> <td>{millisToString(entry.duration)}</td>
<td>{millisToString(event.timeWarning)}</td> <td>{millisToString(entry.timeWarning)}</td>
<td>{millisToString(event.timeDanger)}</td> <td>{millisToString(entry.timeDanger)}</td>
<td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td> <td className={style.center}>{countToEnd && <Tag>{countToEnd}</Tag>}</td>
<td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td> <td className={style.center}>{isPublic && <Tag>{isPublic}</Tag>}</td>
<td>{skip && <Tag>{skip}</Tag>}</td> <td>{skip && <Tag>{skip}</Tag>}</td>
<td style={{ ...colour }}>{event.colour}</td> <td style={{ ...colour }}>{entry.colour}</td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.timerType}</Tag> <Tag>{entry.timerType}</Tag>
</td> </td>
<td className={style.center}> <td className={style.center}>
<Tag>{event.endAction}</Tag> <Tag>{entry.endAction}</Tag>
</td> </td>
{isOntimeEvent(event) && {isOntimeEvent(entry) &&
fieldKeys.map((field) => { fieldKeys.map((field) => {
let value = ''; let value = '';
if (field in event.custom) { if (field in entry.custom) {
value = event.custom[field]; value = entry.custom[field];
} }
return <td key={field}>{value}</td>; return <td key={field}>{value}</td>;
})} })}
<td className={style.center}> <td className={style.center}>
<Tag>{event.id}</Tag> <Tag>{entry.id}</Tag>
</td> </td>
</tr> </tr>
{event.note && ( {entry.note && (
<tr> <tr>
<td colSpan={99} className={style.secondaryRow}> <td colSpan={99} className={style.secondaryRow}>
Note: {event.note} Note: {entry.note}
</td> </td>
</tr> </tr>
)} )}
@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query'; 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 { ImportMap } from 'ontime-utils';
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants'; import { CUSTOM_FIELDS, RUNDOWN } from '../../../../common/api/constants';
@@ -75,9 +75,9 @@ export default function useGoogleSheet() {
}; };
/** applies rundown and customFields to current project */ /** applies rundown and customFields to current project */
const importRundown = async (rundown: OntimeRundown, customFields: CustomFields) => { const importRundown = async (rundowns: ProjectRundowns, customFields: CustomFields) => {
try { try {
await patchData({ rundown, customFields }); await patchData({ rundowns, customFields });
// we are unable to optimistically set the rundown since we need // we are unable to optimistically set the rundown since we need
// it to be normalised // it to be normalised
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -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 { defaultImportMap, ImportMap } from 'ontime-utils';
import { create } from 'zustand'; import { create } from 'zustand';
@@ -15,8 +15,8 @@ type SheetStore = {
setAuthenticationStatus: (status: AuthenticationStatus) => void; setAuthenticationStatus: (status: AuthenticationStatus) => void;
// we get this from a preview response // we get this from a preview response
rundown: OntimeRundown | null; rundown: Rundown | null;
setRundown: (rundown: OntimeRundown | null) => void; setRundown: (rundown: Rundown | null) => void;
// we get this from a preview response // we get this from a preview response
customFields: CustomFields | null; customFields: CustomFields | null;
@@ -60,7 +60,7 @@ export const useSheetStore = create<SheetStore>((set, get) => ({
setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }), setAuthenticationStatus: (status: AuthenticationStatus) => set({ authenticationStatus: status }),
setRundown: (rundown: OntimeRundown | null) => set({ rundown }), setRundown: (rundown: Rundown | null) => set({ rundown }),
setCustomFields: (customFields: CustomFields | null) => set({ customFields }), setCustomFields: (customFields: CustomFields | null) => set({ customFields }),
@@ -126,8 +126,8 @@ export default function Operator() {
let isPast = Boolean(featureData.selectedEventId); let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast')); const hidePast = isStringBoolean(searchParams.get('hidepast'));
const { firstEvent } = getFirstEventNormal(data.rundown, data.order); const { firstEvent } = getFirstEventNormal(data.entries, data.order);
const { lastEvent } = getLastEventNormal(data.rundown, data.order); const { lastEvent } = getLastEventNormal(data.entries, data.order);
return ( return (
<div className={style.operatorContainer}> <div className={style.operatorContainer}>
@@ -152,7 +152,7 @@ export default function Operator() {
<div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}> <div className={style.operatorEvents} onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{data.order.map((eventId) => { {data.order.map((eventId) => {
const entry = data.rundown[eventId]; const entry = data.entries[eventId];
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
const isSelected = featureData.selectedEventId === entry.id; const isSelected = featureData.selectedEventId === entry.id;
if (isSelected) { if (isSelected) {
+35 -28
View File
@@ -1,15 +1,16 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react'; import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; 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 { useHotkeys } from '@mantine/hooks';
import { import {
type EntryId,
type MaybeString,
type PlayableEvent,
type Rundown,
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeString,
PlayableEvent,
Playback, Playback,
RundownCached,
SupportedEvent, SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
import { import {
@@ -21,6 +22,7 @@ import {
getPreviousBlockNormal, getPreviousBlockNormal,
getPreviousNormal, getPreviousNormal,
isNewLatest, isNewLatest,
reorderArray,
} from 'ontime-utils'; } from 'ontime-utils';
import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction'; import { type EventOptions, useEventAction } from '../../common/hooks/useEventAction';
@@ -39,12 +41,12 @@ import style from './Rundown.module.scss';
const RundownEntry = lazy(() => import('./RundownEntry')); const RundownEntry = lazy(() => import('./RundownEntry'));
interface RundownProps { interface RundownProps {
data: RundownCached; data: Rundown;
} }
export default function Rundown({ data }: RundownProps) { export default function Rundown({ data }: RundownProps) {
const { order, rundown } = data; const { order, entries } = data;
const [statefulEntries, setStatefulEntries] = useState(order); const [statefulEntries, setStatefulEntries] = useState<EntryId[]>(order);
const featureData = useRundownEditor(); const featureData = useRundownEditor();
const { addEvent, reorderEvent, deleteEvent } = useEventAction(); const { addEvent, reorderEvent, deleteEvent } = useEventAction();
@@ -65,30 +67,30 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback( const deleteAtCursor = useCallback(
(cursor: string | null) => { (cursor: string | null) => {
if (!cursor) return; if (!cursor) return;
const { entry, index } = getPreviousNormal(rundown, order, cursor); const { entry, index } = getPreviousNormal(entries, order, cursor);
deleteEvent([cursor]); deleteEvent([cursor]);
if (entry && index !== null) { if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index }); setSelectedEvents({ id: entry.id, selectMode: 'click', index });
} }
}, },
[rundown, order, deleteEvent, setSelectedEvents], [entries, order, deleteEvent, setSelectedEvents],
); );
const insertCopyAtId = useCallback( const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => { (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) { if (copyId === null) {
// we cant clone without selection // we cant clone without selection
return; return;
} }
const cloneEntry = rundown[copyId]; const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEvent.Event) { if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top //if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry); const newEvent = cloneEvent(cloneEntry);
addEvent(newEvent, { after: adjustedCursor ?? undefined }); addEvent(newEvent, { after: adjustedCursor ?? undefined });
} }
}, },
[addEvent, order, rundown], [addEvent, order, entries],
); );
const insertAtId = useCallback( const insertAtId = useCallback(
@@ -124,7 +126,7 @@ export default function Rundown({ data }: RundownProps) {
let newCursor = cursor; let newCursor = cursor;
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction // 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)) { if (isOntimeBlock(selected)) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); 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 // otherwise we select the next or previous
const selected = const selected =
direction === 'up' direction === 'up'
? getPreviousBlockNormal(rundown, order, newCursor) ? getPreviousBlockNormal(entries, order, newCursor)
: getNextBlockNormal(rundown, order, newCursor); : getNextBlockNormal(entries, order, newCursor);
if (selected.entry !== null && selected.index !== null) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const selectEntry = useCallback( const selectEntry = useCallback(
@@ -158,7 +160,7 @@ export default function Rundown({ data }: RundownProps) {
if (cursor === null) { if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists // 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) { if (selected !== null) {
setSelectedEvents({ id: selected.id, selectMode: 'click', index: direction === 'up' ? order.length : 0 }); 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 // otherwise we select the next or previous
const selected = 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) { if (selected.entry !== null && selected.index !== null) {
setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index }); setSelectedEvents({ id: selected.entry.id, selectMode: 'click', index: selected.index });
} }
}, },
[order, rundown, setSelectedEvents], [order, entries, setSelectedEvents],
); );
const moveEntry = useCallback( const moveEntry = useCallback(
@@ -182,14 +184,14 @@ export default function Rundown({ data }: RundownProps) {
return; return;
} }
const { index } = 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) { if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1; const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEvent(cursor, offsetIndex, index); reorderEvent(cursor, offsetIndex, index);
} }
}, },
[order, reorderEvent, rundown], [order, reorderEvent, entries],
); );
// shortcuts // shortcuts
@@ -238,6 +240,9 @@ export default function Rundown({ data }: RundownProps) {
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index }); setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]); }, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
/**
* On drag end, we reorder the events
*/
const handleOnDragEnd = (event: DragEndEvent) => { const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
@@ -245,9 +250,10 @@ export default function Rundown({ data }: RundownProps) {
if (active.id !== over?.id) { if (active.id !== over?.id) {
const fromIndex = active.data.current?.sortable.index; const fromIndex = active.data.current?.sortable.index;
const toIndex = over.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) => { setStatefulEntries((currentEntries) => {
return arrayMove(currentEntries, fromIndex, toIndex); return reorderArray(currentEntries, fromIndex, toIndex);
}); });
reorderEvent(String(active.id), 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 // last event is used to calculate relative timings
let lastEvent: PlayableEvent | undefined; // used by indicators let lastEvent: PlayableEvent | null = null; // used by indicators
let thisEvent: PlayableEvent | undefined; let thisEvent: PlayableEvent | null = null;
// previous entry is used to infer position in the rundown for new events // previous entry is used to infer position in the rundown for new events
let previousEntryId: string | undefined; let previousEntryId: MaybeString = null;
let thisId = previousEntryId; let thisId: MaybeString = null;
let eventIndex = 0; let eventIndex = 0;
// all events before the current selected are in the past // all events before the current selected are in the past
@@ -272,6 +278,7 @@ export default function Rundown({ data }: RundownProps) {
let totalGap = 0; let totalGap = 0;
const isEditMode = appMode === AppMode.Edit; const isEditMode = appMode === AppMode.Edit;
let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event let isLinkedToLoaded = true; //check if the event can link all the way back to the currently playing event
return ( return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'> <div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}> <DndContext onDragEnd={handleOnDragEnd} sensors={sensors} collisionDetection={closestCenter}>
@@ -281,7 +288,7 @@ export default function Rundown({ data }: RundownProps) {
// we iterate through a stateful copy of order to make the operations smoother // 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 // 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 // instead of writing all the logic guards, we simply short circuit rendering here
const entry = rundown[entryId]; const entry = entries[entryId];
if (!entry) { if (!entry) {
return null; return null;
} }
@@ -1,5 +1,14 @@
import { useCallback } from 'react'; 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 { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn'; import useMemoisedFn from '../../common/hooks/useMemoisedFn';
@@ -28,13 +37,13 @@ export type EventItemActions =
interface RundownEntryProps { interface RundownEntryProps {
type: SupportedEvent; type: SupportedEvent;
isPast: boolean; isPast: boolean;
data: OntimeRundownEntry; data: OntimeEntry;
loaded: boolean; loaded: boolean;
eventIndex: number; eventIndex: number;
hasCursor: boolean; hasCursor: boolean;
isNext: boolean; isNext: boolean;
isNextDay: boolean; isNextDay: boolean;
previousEntryId?: string; previousEntryId: MaybeString;
previousEventId?: string; previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing 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 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 ( return (
<EventBlock <EventBlock
eventId={data.id} eventId={data.id}
@@ -167,7 +176,7 @@ export default function RundownEntry(props: RundownEntryProps) {
timerType={data.timerType} timerType={data.timerType}
title={data.title} title={data.title}
note={data.note} note={data.note}
delay={data.delay ?? 0} delay={data.delay}
colour={data.colour} colour={data.colour}
isPast={isPast} isPast={isPast}
isNext={isNext} isNext={isNext}
@@ -184,9 +193,15 @@ export default function RundownEntry(props: RundownEntryProps) {
actionHandler={actionHandler} actionHandler={actionHandler}
/> />
); );
} else if (data.type === SupportedEvent.Block) { } else if (isOntimeBlock(data)) {
return <BlockBlock data={data} hasCursor={hasCursor} onDelete={() => actionHandler('delete')} />; return (
} else if (data.type === SupportedEvent.Delay) { <BlockBlock data={data} hasCursor={hasCursor}>
{data.events.map((eventId) => {
return <div key={eventId}>{eventId}</div>;
})}
</BlockBlock>
);
} else if (isOntimeDelay(data)) {
return <DelayBlock data={data} hasCursor={hasCursor} />; return <DelayBlock data={data} hasCursor={hasCursor} />;
} }
return null; return null;
@@ -22,7 +22,3 @@
.drag { .drag {
@include drag-style; @include drag-style;
} }
.actionMenu {
justify-self: flex-end;
}
@@ -1,4 +1,4 @@
import { useRef } from 'react'; import { PropsWithChildren, useRef } from 'react';
import { IoReorderTwo } from 'react-icons/io5'; import { IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
@@ -7,18 +7,15 @@ import { OntimeBlock } from 'ontime-types';
import { cx } from '../../../common/utils/styleUtils'; import { cx } from '../../../common/utils/styleUtils';
import EditableBlockTitle from '../common/EditableBlockTitle'; import EditableBlockTitle from '../common/EditableBlockTitle';
import BlockDelete from './BlockDelete';
import style from './BlockBlock.module.scss'; import style from './BlockBlock.module.scss';
interface BlockBlockProps { interface BlockBlockProps {
data: OntimeBlock; data: OntimeBlock;
hasCursor: boolean; hasCursor: boolean;
onDelete: () => void;
} }
export default function BlockBlock(props: BlockBlockProps) { export default function BlockBlock(props: PropsWithChildren<BlockBlockProps>) {
const { data, hasCursor, onDelete } = props; const { data, hasCursor, children } = props;
const handleRef = useRef<null | HTMLSpanElement>(null); const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -46,7 +43,8 @@ export default function BlockBlock(props: BlockBlockProps) {
<IoReorderTwo /> <IoReorderTwo />
</span> </span>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' /> <EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<BlockDelete onDelete={onDelete} /> <button>+++</button>
<div>{children}</div>
</div> </div>
); );
} }
@@ -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 (
<IconButton
aria-label='Delete'
size='sm'
icon={<IoTrash />}
variant='ontime-subtle'
color='#FA5656'
onClick={onDelete}
isDisabled={isRunMode}
/>
);
}
@@ -15,23 +15,22 @@ interface CuesheetEventEditorProps {
export default function CuesheetEventEditor(props: CuesheetEventEditorProps) { export default function CuesheetEventEditor(props: CuesheetEventEditorProps) {
const { eventId } = props; const { eventId } = props;
const { data } = useRundown(); const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => { useEffect(() => {
if (order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[eventId]; const event = data.entries[eventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [data, eventId, order, rundown]); }, [eventId, data.order, data.entries]);
if (!event) { if (!event) {
return null; return null;
@@ -13,29 +13,28 @@ import style from './EventEditor.module.scss';
export default function RundownEventEditor() { export default function RundownEventEditor() {
const selectedEvents = useEventSelection((state) => state.selectedEvents); const selectedEvents = useEventSelection((state) => state.selectedEvents);
const { data } = useRundown(); const { data } = useRundown();
const { order, rundown } = data;
const [event, setEvent] = useState<OntimeEvent | null>(null); const [event, setEvent] = useState<OntimeEvent | null>(null);
useEffect(() => { useEffect(() => {
if (order.length === 0) { if (data.order.length === 0) {
setEvent(null); setEvent(null);
return; return;
} }
const selectedEventId = order.find((eventId) => selectedEvents.has(eventId)); const selectedEventId = data.order.find((entryId) => selectedEvents.has(entryId));
if (!selectedEventId) { if (!selectedEventId) {
setEvent(null); setEvent(null);
return; return;
} }
const event = rundown[selectedEventId]; const event = data.entries[selectedEventId];
if (event && isOntimeEvent(event)) { if (event && isOntimeEvent(event)) {
setEvent(event); setEvent(event);
} else { } else {
setEvent(null); setEvent(null);
} }
}, [order, rundown, selectedEvents]); }, [data.order, data.entries, selectedEvents]);
if (!event) { if (!event) {
return <EventEditorEmpty />; return <EventEditorEmpty />;
@@ -1,7 +1,7 @@
import { memo, useCallback, useRef } from 'react'; import { memo, useCallback, useRef } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { SupportedEvent } from 'ontime-types'; import { MaybeString, SupportedEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import { useEmitLog } from '../../../common/stores/logger'; import { useEmitLog } from '../../../common/stores/logger';
@@ -9,7 +9,7 @@ import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
previousEventId?: string; previousEventId: MaybeString;
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react'; 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 { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants'; import { RUNDOWN } from '../../common/api/constants';
@@ -33,7 +33,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on ctrl + click, we toggle the selection of that event // on ctrl + click, we toggle the selection of that event
if (selectMode === 'ctrl') { if (selectMode === 'ctrl') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// if it doesnt exist, simply add to the list and set an anchor // if it doesnt exist, simply add to the list and set an anchor
@@ -50,7 +50,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents.delete(id); selectedEvents.delete(id);
const nextIndex = rundownData.order.findIndex( 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 // if we didnt find anything after, set the anchor to the last event
@@ -62,13 +62,13 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
// on shift + click, we select a range of events up to the clicked event // on shift + click, we select a range of events up to the clicked event
if (selectMode === 'shift') { if (selectMode === 'shift') {
const rundownData = ontimeQueryClient.getQueryData<RundownCached>(RUNDOWN); const rundownData = ontimeQueryClient.getQueryData<Rundown>(RUNDOWN);
if (!rundownData) return; if (!rundownData) return;
// get list of rundown with only ontime events // get list of rundown with only ontime events
const events: OntimeEvent[] = []; const events: OntimeEvent[] = [];
rundownData.order.forEach((eventId) => { rundownData.order.forEach((eventId) => {
const event = rundownData.rundown[eventId]; const event = rundownData.entries[eventId];
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
events.push(event); events.push(event);
} }
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
OntimeEntry,
OntimeEvent, OntimeEvent,
OntimeRundownEntry,
Playback, Playback,
ProjectData, ProjectData,
Runtime, Runtime,
@@ -72,7 +72,7 @@ export default function Countdown(props: CountdownProps) {
} }
if (followThis !== null) { if (followThis !== null) {
setFollow(followThis); 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; const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent); setDelay(delayToEvent);
} }
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom'; 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 Empty from '../../../common/components/state/Empty';
import { formatTime } from '../../../common/utils/time'; import { formatTime } from '../../../common/utils/time';
@@ -10,7 +10,7 @@ import { sanitiseTitle } from './countdown.helpers';
import './Countdown.scss'; import './Countdown.scss';
interface CountdownSelectProps { interface CountdownSelectProps {
events: OntimeRundownEntry[]; events: OntimeEntry[];
} }
const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' }; const scheduleFormat = { format12: 'hh:mm a', format24: 'HH:mm' };
@@ -19,9 +19,7 @@ export default function CountdownSelect(props: CountdownSelectProps) {
const { events } = props; const { events } = props;
const { getLocalizedString } = useTranslation(); const { getLocalizedString } = useTranslation();
const filteredEvents = events.filter( const filteredEvents = events.filter((event: OntimeEntry) => event.type === SupportedEvent.Event) as OntimeEvent[];
(event: OntimeRundownEntry) => event.type === SupportedEvent.Event,
) as OntimeEvent[];
return ( return (
<div className='event-select' data-testid='countdown__select'> <div className='event-select' data-testid='countdown__select'>
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom'; 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 { Playback } from 'ontime-types';
import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils'; import { millisToString, removeSeconds, secondsInMillis } from 'ontime-utils';
@@ -17,7 +17,7 @@ import StudioClockSchedule from './StudioClockSchedule';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockProps { interface StudioClockProps {
backstageEvents: OntimeRundown; backstageEvents: OntimeEntry[];
eventNext: OntimeEvent | null; eventNext: OntimeEvent | null;
general: ProjectData; general: ProjectData;
isMirrored: boolean; isMirrored: boolean;
@@ -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 { formatTime } from '../../../common/utils/time';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime'; import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
@@ -8,7 +8,7 @@ import { trimRundown } from './studioClock.utils';
import './StudioClock.scss'; import './StudioClock.scss';
interface StudioClockScheduleProps { interface StudioClockScheduleProps {
rundown: OntimeRundown; rundown: OntimeEntry[];
selectedId: MaybeString; selectedId: MaybeString;
nextId: MaybeString; nextId: MaybeString;
onAir: boolean; onAir: boolean;
@@ -8,7 +8,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { isOntimeEvent, OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { isOntimeEvent, OntimeEntry, OntimeEvent } from 'ontime-types';
import { usePartialRundown } from '../../../common/hooks-query/useRundown'; import { usePartialRundown } from '../../../common/hooks-query/useRundown';
@@ -36,7 +36,7 @@ export const ScheduleProvider = ({
isBackstage = false, isBackstage = false,
}: PropsWithChildren<ScheduleProviderProps>) => { }: PropsWithChildren<ScheduleProviderProps>) => {
const { cycleInterval, stopCycle } = useScheduleOptions(); const { cycleInterval, stopCycle } = useScheduleOptions();
const { data: events } = usePartialRundown((event: OntimeRundownEntry) => { const { data: events } = usePartialRundown((event: OntimeEntry) => {
if (isBackstage) { if (isBackstage) {
return isOntimeEvent(event); return isOntimeEvent(event);
} }
@@ -9,12 +9,12 @@ import {
useSensors, useSensors,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import useColumnManager from '../cuesheet-table/useColumnManager'; import useColumnManager from '../cuesheet-table/useColumnManager';
interface CuesheetDndProps { interface CuesheetDndProps {
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
} }
export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) { export default function CuesheetDnd(props: PropsWithChildren<CuesheetDndProps>) {
@@ -1,7 +1,7 @@
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTableNav } from '@table-nav/react'; import { useTableNav } from '@table-nav/react';
import { ColumnDef, getCoreRowModel, useReactTable } from '@tanstack/react-table'; 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 { useEventAction } from '../../../common/hooks/useEventAction';
import useFollowComponent from '../../../common/hooks/useFollowComponent'; import useFollowComponent from '../../../common/hooks/useFollowComponent';
@@ -16,8 +16,8 @@ import useColumnManager from './useColumnManager';
import style from './CuesheetTable.module.scss'; import style from './CuesheetTable.module.scss';
interface CuesheetTableProps { interface CuesheetTableProps {
data: OntimeRundown; data: OntimeEntry[];
columns: ColumnDef<OntimeRundownEntry>[]; columns: ColumnDef<OntimeEntry>[];
showModal: (eventId: MaybeString) => void; showModal: (eventId: MaybeString) => void;
} }
@@ -1,7 +1,7 @@
import { MutableRefObject } from 'react'; import { MutableRefObject } from 'react';
import { RowModel, Table } from '@tanstack/react-table'; import { RowModel, Table } from '@tanstack/react-table';
import Color from 'color'; 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 { useSelectedEventId } from '../../../../common/hooks/useSocket';
import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate'; import { lazyEvaluate } from '../../../../common/utils/lazyEvaluate';
@@ -13,9 +13,9 @@ import DelayRow from './DelayRow';
import EventRow from './EventRow'; import EventRow from './EventRow';
interface CuesheetBodyProps { interface CuesheetBodyProps {
rowModel: RowModel<OntimeRundownEntry>; rowModel: RowModel<OntimeEntry>;
selectedRef: MutableRefObject<HTMLTableRowElement | null>; selectedRef: MutableRefObject<HTMLTableRowElement | null>;
table: Table<OntimeRundownEntry>; table: Table<OntimeEntry>;
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
} }
@@ -1,6 +1,6 @@
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table'; import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import { getAccessibleColour } from '../../../../common/utils/styleUtils'; import { getAccessibleColour } from '../../../../common/utils/styleUtils';
import { useCuesheetOptions } from '../../cuesheet.options'; import { useCuesheetOptions } from '../../cuesheet.options';
@@ -10,7 +10,7 @@ import { SortableCell } from './SortableCell';
import style from '../CuesheetTable.module.scss'; import style from '../CuesheetTable.module.scss';
interface CuesheetHeaderProps { interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[]; headerGroups: HeaderGroup<OntimeEntry>[];
} }
export default function CuesheetHeader(props: CuesheetHeaderProps) { export default function CuesheetHeader(props: CuesheetHeaderProps) {
@@ -2,7 +2,7 @@ import { memo, MutableRefObject, useLayoutEffect, useRef, useState } from 'react
import { IoEllipsisHorizontal } from 'react-icons/io5'; import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table'; import { flexRender, Table } from '@tanstack/react-table';
import Color from 'color'; import Color from 'color';
import { OntimeEvent, OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry, OntimeEvent } from 'ontime-types';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils'; import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
@@ -21,7 +21,7 @@ interface EventRowProps {
skip?: boolean; skip?: boolean;
colour?: string; colour?: string;
rowBgColour?: string; rowBgColour?: string;
table: Table<OntimeRundownEntry>; table: Table<OntimeEntry>;
/** hack to force re-rendering of the row when the column sizes change */ /** hack to force re-rendering of the row when the column sizes change */
columnSizing: Record<string, number>; columnSizing: Record<string, number>;
} }
@@ -2,12 +2,12 @@ import { CSSProperties, ReactNode } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Header } from '@tanstack/react-table'; import { Header } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
import styles from '../CuesheetTable.module.scss'; import styles from '../CuesheetTable.module.scss';
interface SortableCellProps { interface SortableCellProps {
header: Header<OntimeRundownEntry, unknown>; header: Header<OntimeEntry, unknown>;
style: CSSProperties; style: CSSProperties;
children: ReactNode; children: ReactNode;
} }
@@ -1,6 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { CellContext, ColumnDef } from '@tanstack/react-table'; 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 { millisToString, removeSeconds } from 'ontime-utils';
import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator'; import DelayIndicator from '../../../../common/components/delay-indicator/DelayIndicator';
@@ -11,7 +11,7 @@ import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell'; import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput'; import TimeInput from './TimeInput';
function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeStart({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -39,7 +39,7 @@ function MakeStart({ getValue, row, table }: CellContext<OntimeRundownEntry, unk
); );
} }
function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeEnd({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -67,7 +67,7 @@ function MakeEnd({ getValue, row, table }: CellContext<OntimeRundownEntry, unkno
); );
} }
function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeDuration({ getValue, row, table }: CellContext<OntimeEntry, unknown>) {
if (!table.options.meta) { if (!table.options.meta) {
return null; return null;
} }
@@ -87,7 +87,7 @@ function MakeDuration({ getValue, row, table }: CellContext<OntimeRundownEntry,
); );
} }
function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeMultiLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -101,12 +101,12 @@ function MakeMultiLineField({ row, column, table }: CellContext<OntimeRundownEnt
return null; return null;
} }
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function LazyImage({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -124,7 +124,7 @@ function LazyImage({ row, column, table }: CellContext<OntimeRundownEntry, unkno
return <EditableImage initialValue={initialValue} updateValue={update} />; return <EditableImage initialValue={initialValue} updateValue={update} />;
} }
function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeSingleLineField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, false); table.options.meta?.handleUpdate(row.index, column.id, newValue, false);
@@ -138,12 +138,12 @@ function MakeSingleLineField({ row, column, table }: CellContext<OntimeRundownEn
return null; return null;
} }
const initialValue = event[column.id as keyof OntimeRundownEntry] ?? ''; const initialValue = event[column.id as keyof OntimeEntry] ?? '';
return <SingleLineCell initialValue={initialValue} handleUpdate={update} />; return <SingleLineCell initialValue={initialValue as string} handleUpdate={update} />;
} }
function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry, unknown>) { function MakeCustomField({ row, column, table }: CellContext<OntimeEntry, unknown>) {
const update = useCallback( const update = useCallback(
(newValue: string) => { (newValue: string) => {
table.options.meta?.handleUpdate(row.index, column.id, newValue, true); table.options.meta?.handleUpdate(row.index, column.id, newValue, true);
@@ -161,7 +161,7 @@ function MakeCustomField({ row, column, table }: CellContext<OntimeRundownEntry,
return <MultiLineCell initialValue={initialValue} handleUpdate={update} />; return <MultiLineCell initialValue={initialValue} handleUpdate={update} />;
} }
export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeRundownEntry>[] { export function makeCuesheetColumns(customFields: CustomFields): ColumnDef<OntimeEntry>[] {
const dynamicCustomFields = Object.keys(customFields).map((key) => ({ const dynamicCustomFields = Object.keys(customFields).map((key) => ({
accessorKey: key, accessorKey: key,
id: key, id: key,
@@ -1,7 +1,7 @@
import { memo, ReactNode } from 'react'; import { memo, ReactNode } from 'react';
import { Button, Checkbox } from '@chakra-ui/react'; import { Button, Checkbox } from '@chakra-ui/react';
import { Column } from '@tanstack/react-table'; 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'; import * as Editor from '../../../../features/editors/editor-utils/EditorUtils';
@@ -14,7 +14,7 @@ const buttonProps = {
}; };
interface CuesheetTableSettingsProps { interface CuesheetTableSettingsProps {
columns: Column<OntimeRundownEntry, unknown>[]; columns: Column<OntimeEntry, unknown>[];
handleResetResizing: () => void; handleResetResizing: () => void;
handleResetReordering: () => void; handleResetReordering: () => void;
handleClearToggles: () => void; handleClearToggles: () => void;
@@ -1,9 +1,9 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@mantine/hooks'; import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef } from '@tanstack/react-table'; import { ColumnDef } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types'; import { OntimeEntry } from 'ontime-types';
export default function useColumnManager(columns: ColumnDef<OntimeRundownEntry>[]) { export default function useColumnManager(columns: ColumnDef<OntimeEntry>[]) {
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} }); const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({ const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order', key: 'table-order',
@@ -3,8 +3,8 @@ import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
MaybeNumber, MaybeNumber,
OntimeEntry,
OntimeEntryCommonKeys, OntimeEntryCommonKeys,
OntimeRundown,
ProjectData, ProjectData,
} from 'ontime-types'; } from 'ontime-types';
import { millisToString } from 'ontime-utils'; 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 * @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 // create metadata header row
const data = [['Ontime · Rundown export']]; const data = [['Ontime · Rundown export']];
if (headerData.title) data.push([`Project title: ${headerData.title}`]); if (headerData.title) data.push([`Project title: ${headerData.title}`]);
+2 -2
View File
@@ -1,6 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks'; 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 { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers'; import TimelineMarkers from './timeline-markers/TimelineMarkers';
@@ -11,7 +11,7 @@ import style from './Timeline.module.scss';
interface TimelineProps { interface TimelineProps {
firstStart: number; firstStart: number;
rundown: OntimeRundown; rundown: OntimeEntry[];
selectedEventId: string | null; selectedEventId: string | null;
totalDuration: number; totalDuration: number;
} }
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; 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 { import {
dayInMs, dayInMs,
getEventWithId, getEventWithId,
@@ -87,7 +87,7 @@ interface ScopedRundownData {
totalDuration: number; totalDuration: number;
} }
export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeString): ScopedRundownData { export function useScopedRundown(rundown: OntimeEntry[], selectedEventId: MaybeString): ScopedRundownData {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const data = useMemo(() => { const data = useMemo(() => {
@@ -102,7 +102,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
let selectedIndex = selectedEventId ? Infinity : -1; let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null; let firstStart = null;
let totalDuration = 0; let totalDuration = 0;
let lastEntry: PlayableEvent | undefined; let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < rundown.length; i++) { for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i]; const currentEntry = rundown[i];
@@ -164,7 +164,7 @@ type UpcomingEvents = {
/** /**
* Returns upcoming events from current: now, next and followedBy * 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) { if (events.length === 0) {
return { now: null, next: null, followedBy: null }; return { now: null, next: null, followedBy: null };
} }
@@ -157,7 +157,12 @@ async function saveChanges(patch: Partial<AutomationSettings>) {
const automation = getDataProvider().getAutomation(); const automation = getDataProvider().getAutomation();
// remove undefined keys from object, we probably want a better solution // 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 }); await getDataProvider().setAutomation({ ...automation, ...patch });
} }
+2 -2
View File
@@ -18,9 +18,9 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) { export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try { try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body; const { rundowns, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const patchDb: DatabaseModel = { const patchDb: DatabaseModel = {
rundown, rundowns,
project, project,
settings, settings,
viewSettings, viewSettings,
+1 -1
View File
@@ -18,7 +18,7 @@ const filterImageFile = (_req: Request, file: Express.Multer.File, cb: FileFilte
} else { } else {
cb(null, false); cb(null, false);
} }
} };
// Build multer uploader for a single file // Build multer uploader for a single file
export const uploadProjectFile = multer({ export const uploadProjectFile = multer({
+1 -1
View File
@@ -58,7 +58,7 @@ export const validatePatchProject = [
next(); next();
}, },
body('rundown').isArray().optional({ nullable: false }), body('rundowns').isObject().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }),
body('settings').isObject().optional({ nullable: false }), body('settings').isObject().optional({ nullable: false }),
body('viewSettings').isObject().optional({ nullable: false }), body('viewSettings').isObject().optional({ nullable: false }),
@@ -5,6 +5,7 @@
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js'; import { generateRundownPreview, listWorksheets, saveExcelFile } from './excel.service.js';
import { CustomFields, Rundown } from 'ontime-types';
export async function postExcel(req: Request, res: Response) { export async function postExcel(req: Request, res: Response) {
try { try {
@@ -29,7 +30,10 @@ export async function getWorksheets(req: Request, res: Response) {
* parses an Excel spreadsheet * parses an Excel spreadsheet
* @returns parsed result * @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 { try {
const { options } = req.body; const { options } = req.body;
const data = generateRundownPreview(options); const data = generateRundownPreview(options);
@@ -3,8 +3,8 @@
* Google Sheets * Google Sheets
*/ */
import { CustomFields, OntimeRundown } from 'ontime-types'; import { CustomFields, Rundown } from 'ontime-types';
import type { ImportMap } from 'ontime-utils'; import { type ImportMap } from 'ontime-utils';
import { extname } from 'path'; import { extname } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
@@ -12,7 +12,7 @@ import xlsx from 'xlsx';
import type { WorkBook } from 'xlsx'; import type { WorkBook } from 'xlsx';
import { parseExcel } from '../../utils/parser.js'; 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 { deleteFile } from '../../utils/parserUtils.js';
import { getCustomFields } from '../../services/rundown-service/rundownCache.js'; import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
@@ -34,7 +34,7 @@ export function listWorksheets(): string[] {
return excelData.SheetNames; 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]; const data = excelData.Sheets[options.worksheet];
if (!data) { 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 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 // we run the parsed data through an extra step to ensure the objects shape
const { rundown, customFields } = parseRundown(dataFromExcel); const Rundown = parseRundown(dataFromExcel.rundown, parsedCustomFields);
if (rundown.length === 0) { if (Rundown.order.length === 0) {
throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`); throw new Error(`Could not find data to import in the worksheet: ${options.worksheet}`);
} }
// clear the data // clear the data
excelData = xlsx.utils.book_new(); excelData = xlsx.utils.book_new();
return { rundown, customFields }; return { rundown: Rundown, customFields: parsedCustomFields };
} }
@@ -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 { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
@@ -14,19 +14,19 @@ import {
reorderEvent, reorderEvent,
swapEvents, swapEvents,
} from '../../services/rundown-service/RundownService.js'; } 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<OntimeRundown>) { export async function rundownGetAll(_req: Request, res: Response<ProjectRundownsList>) {
const rundown = getRundown(); const rundown = getCurrentRundown();
res.json(rundown); res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
} }
export async function rundownGetNormalised(_req: Request, res: Response<RundownCached>) { export async function rundownGetCurrent(_req: Request, res: Response<Rundown>) {
const cachedRundown = getNormalisedRundown(); const cachedRundown = getCurrentRundown();
res.json(cachedRundown); res.json(cachedRundown);
} }
export async function rundownGetById(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownGetById(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
const { eventId } = req.params; const { eventId } = req.params;
try { try {
@@ -43,7 +43,7 @@ export async function rundownGetById(req: Request, res: Response<OntimeRundownEn
} }
} }
export async function rundownPost(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownPost(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -57,7 +57,7 @@ export async function rundownPost(req: Request, res: Response<OntimeRundownEntry
} }
} }
export async function rundownPut(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownPut(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -86,7 +86,7 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
} }
} }
export async function rundownReorder(req: Request, res: Response<OntimeRundownEntry | ErrorResponse>) { export async function rundownReorder(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) { if (failEmptyObjects(req.body, res)) {
return; return;
} }
@@ -7,7 +7,7 @@ import {
rundownDelete, rundownDelete,
rundownGetAll, rundownGetAll,
rundownGetById, rundownGetById,
rundownGetNormalised, rundownGetCurrent,
rundownPost, rundownPost,
rundownPut, rundownPut,
rundownReorder, rundownReorder,
@@ -25,8 +25,8 @@ import {
export const router = express.Router(); export const router = express.Router();
router.get('/', rundownGetAll); // not used in Ontime frontend router.get('/', rundownGetAll);
router.get('/normalised', rundownGetNormalised); router.get('/current', rundownGetCurrent);
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost); router.post('/', rundownPostValidator, rundownPost);
@@ -6,7 +6,7 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { readFileSync } from 'fs'; 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 { deleteFile } from '../../utils/parserUtils.js';
import { import {
@@ -87,7 +87,7 @@ export async function readFromSheet(
req: Request, req: Request,
res: Response< res: Response<
| { | {
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
} }
| ErrorResponse | ErrorResponse
@@ -1,12 +1,13 @@
import { import {
ProjectData, ProjectData,
OntimeRundown,
ViewSettings, ViewSettings,
DatabaseModel, DatabaseModel,
Settings, Settings,
CustomFields, CustomFields,
URLPreset, URLPreset,
AutomationSettings, AutomationSettings,
Rundown,
ProjectRundowns,
} from 'ontime-types'; } from 'ontime-types';
import type { Low } from 'lowdb'; import type { Low } from 'lowdb';
@@ -45,6 +46,7 @@ export function getDataProvider() {
setCustomFields, setCustomFields,
getCustomFields, getCustomFields,
setRundown, setRundown,
mergeRundown,
getSettings, getSettings,
setSettings, setSettings,
getUrlPresets, getUrlPresets,
@@ -78,14 +80,28 @@ async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFie
return db.data.customFields; return db.data.customFields;
} }
async function mergeRundown(
newCustomFields: CustomFields,
newRundowns: ProjectRundowns,
): ReadonlyPromise<{ rundowns: ProjectRundowns; customFields: CustomFields }> {
db.data.customFields = { ...db.data.customFields, ...newCustomFields };
Object.entries(newRundowns).forEach(([id, rundown]) => {
// Note that entries with the same key will be overridden
db.data.rundowns[id] = rundown;
});
await persist();
return { rundowns: db.data.rundowns, customFields: db.data.customFields };
}
function getCustomFields(): Readonly<CustomFields> { function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields; return db.data.customFields;
} }
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> { async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<Rundown> {
db.data.rundown = newData; db.data.rundowns[rundownKey] = newData;
await persist(); await persist();
return db.data.rundown; return db.data.rundowns[rundownKey];
} }
function getSettings(): Readonly<Settings> { function getSettings(): Readonly<Settings> {
@@ -128,8 +144,9 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise<Autom
return db.data.automation; return db.data.automation;
} }
function getRundown(): Readonly<OntimeRundown> { function getRundown(): Readonly<Rundown> {
return db.data.rundown; const firstRundown = Object.keys(db.data.rundowns)[0];
return db.data.rundowns[firstRundown];
} }
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> { async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
@@ -140,7 +157,7 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
db.data.automation = mergedData.automation; db.data.automation = mergedData.automation;
db.data.urlPresets = mergedData.urlPresets; db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields; db.data.customFields = mergedData.customFields;
db.data.rundown = mergedData.rundown; db.data.rundowns = mergedData.rundowns;
await persist(); await persist();
return db.data; return db.data;
@@ -5,7 +5,7 @@ import { DatabaseModel } from 'ontime-types';
*/ */
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel { export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>): DatabaseModel {
const { const {
rundown = existing.rundown, rundowns = {},
project = {}, project = {},
settings = {}, settings = {},
viewSettings = {}, viewSettings = {},
@@ -16,7 +16,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
return { return {
...existing, ...existing,
rundown, rundowns: { ...existing.rundowns, ...rundowns },
project: { ...existing.project, ...project }, project: { ...existing.project, ...project },
settings: { ...existing.settings, ...settings }, settings: { ...existing.settings, ...settings },
viewSettings: { ...existing.viewSettings, ...viewSettings }, viewSettings: { ...existing.viewSettings, ...viewSettings },
@@ -1,93 +1,74 @@
import { DatabaseModel, OntimeRundown, Settings, URLPreset, ViewSettings } from 'ontime-types'; import { DatabaseModel, Settings, URLPreset } from 'ontime-types';
import { demoDb } from '../../../models/demoProject.js';
import { makeOntimeEvent, makeRundown } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { safeMerge } from '../DataProvider.utils.js'; import { safeMerge } from '../DataProvider.utils.js';
describe('safeMerge', () => { describe('safeMerge', () => {
const existing = {
rundown: [],
project: {
title: 'existing title',
description: 'existing description',
publicUrl: 'existing public URL',
backstageUrl: 'existing backstageUrl',
publicInfo: 'existing backstageInfo',
backstageInfo: 'existing backstageInfo',
projectLogo: null,
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
freezeEnd: false,
endMessage: 'existing endMessage',
normalColor: '#ffffffcc',
warningColor: '#FFAB33',
dangerColor: '#ED3333',
},
urlPresets: [],
customFields: {
lighting: { type: 'string', label: 'lighting', colour: 'red' },
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
},
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
triggers: [],
automations: {},
},
} as DatabaseModel;
it('returns existing data if new data is not provided', () => { it('returns existing data if new data is not provided', () => {
const mergedData = safeMerge(existing, {}); const mergedData = safeMerge(demoDb, {});
expect(mergedData).toEqual(existing); expect(mergedData).toEqual(demoDb);
}); });
it('merges the rundown key', () => { it('overrides a rundown with the same key', () => {
const newData = { const newData = makeRundown({
rundown: [{ title: 'item 1' }, { title: 'item 2' }] as OntimeRundown, id: 'demo',
}; entries: {
const mergedData = safeMerge(existing, newData); '1': makeOntimeEvent({ id: '1', title: 'new title' }),
expect(mergedData.rundown).toEqual(newData.rundown); '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', () => { it('merges the project key', () => {
const newData = { const mergedData = safeMerge(demoDb, {
project: { project: {
title: 'new title', title: 'new title',
publicInfo: 'new public info', publicInfo: 'new public info',
backstageInfo: 'new backstage info',
}, },
}; } as Partial<DatabaseModel>);
// @ts-expect-error -- just testing
const mergedData = safeMerge(existing, newData); expect(mergedData.project).toStrictEqual({
expect(mergedData.project).toEqual({
title: 'new title', title: 'new title',
description: 'existing description', description: 'Turin 2022',
publicUrl: 'existing public URL', publicUrl: 'www.getontime.no',
publicInfo: 'new public info', publicInfo: 'new public info',
backstageUrl: 'existing backstageUrl', backstageUrl: 'www.github.com/cpvalente/ontime',
backstageInfo: 'existing backstageInfo', backstageInfo: 'new backstage info',
projectLogo: null, projectLogo: null,
}); });
}); });
it('merges the settings key', () => { it('merges the settings key', () => {
const newData = { const mergedData = safeMerge(demoDb, {
settings: { settings: {
serverPort: 3000, serverPort: 3000,
language: 'pt', language: 'pt',
version: 'new',
} as Settings, } as Settings,
}; });
const mergedData = safeMerge(existing, newData); expect(mergedData.settings).toStrictEqual({
expect(mergedData.settings).toEqual({
app: 'ontime', app: 'ontime',
version: '2.0.0', version: 'new',
serverPort: 3000, serverPort: 3000,
operatorKey: null, operatorKey: null,
editorKey: null, editorKey: null,
@@ -97,41 +78,6 @@ describe('safeMerge', () => {
}); });
it('should merge the urlPresets key when present', () => { it('should merge the urlPresets key when present', () => {
const existingData = {
rundown: [],
project: {
title: '',
description: '',
publicUrl: '',
publicInfo: '',
backstageUrl: '',
backstageInfo: '',
projectLogo: null,
},
settings: {
app: 'ontime',
version: '2.0.0',
serverPort: 4001,
operatorKey: null,
editorKey: null,
timeFormat: '24',
language: 'en',
},
viewSettings: {
overrideStyles: false,
endMessage: '',
} as ViewSettings,
urlPresets: [],
customFields: {},
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
triggers: [],
automations: {},
},
} as DatabaseModel;
const newData = { const newData = {
urlPresets: [ urlPresets: [
{ enabled: true, alias: 'alias1', pathAndParams: '' }, { enabled: true, alias: 'alias1', pathAndParams: '' },
@@ -139,9 +85,9 @@ describe('safeMerge', () => {
] as URLPreset[], ] 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', () => { it('merges customFields into existing object', () => {
+12 -2
View File
@@ -1,8 +1,18 @@
import { DatabaseModel } from 'ontime-types'; import { DatabaseModel, Rundown } from 'ontime-types';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js'; import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
export const defaultRundown: Rundown = {
id: 'default',
title: 'Default',
order: [],
entries: {},
revision: 0,
};
export const dbModel: DatabaseModel = { export const dbModel: DatabaseModel = {
rundown: [], rundowns: {
default: { ...defaultRundown },
},
project: { project: {
title: '', title: '',
description: '', description: '',
+467 -402
View File
@@ -1,410 +1,475 @@
import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types'; import { DatabaseModel, EndAction, SupportedEvent, TimeStrategy, TimerType } from 'ontime-types';
export const demoDb: DatabaseModel = { export const demoDb: DatabaseModel = {
rundown: [ rundowns: {
{ demo: {
type: SupportedEvent.Event, id: 'demo',
id: '32d31', title: 'Eurovision Demo',
cue: 'SF1.01', order: [
title: 'Albania', '32d31',
note: 'SF1.01', '21cd2',
endAction: EndAction.None, '0b371',
timerType: TimerType.CountDown, '3cd28',
countToEnd: false, 'e457f',
linkStart: null, '01e85',
timeStrategy: TimeStrategy.LockEnd, '1c420',
timeStart: 36000000, 'b7737',
timeEnd: 37200000, 'd3a80',
duration: 1200000, '8276c',
isPublic: true, '2340b',
skip: false, 'cb90b',
colour: '', '503c4',
revision: 0, '5e965',
delay: 0, 'bab4a',
dayOffset: 0, 'd3eb1',
gap: 0, ],
timeWarning: 500000, entries: {
timeDanger: 100000, '32d31': {
custom: { type: SupportedEvent.Event,
song: 'Sekret', id: '32d31',
artist: 'Ronela Hajati', 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, 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: { project: {
title: 'Eurovision Song Contest', title: 'Eurovision Song Contest',
description: 'Turin 2022', description: 'Turin 2022',
@@ -416,7 +481,7 @@ export const demoDb: DatabaseModel = {
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
version: '3.3.2', version: '-',
serverPort: 4001, serverPort: 4001,
editorKey: null, editorKey: null,
operatorKey: null, operatorKey: null,
+19 -7
View File
@@ -9,6 +9,7 @@ import {
} from 'ontime-types'; } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'cue'> = { export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
type: SupportedEvent.Event,
title: '', title: '',
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
@@ -22,22 +23,33 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
isPublic: false, isPublic: false,
skip: false, skip: false,
colour: '', colour: '',
type: SupportedEvent.Event, currentBlock: null,
revision: 0, revision: 0, // calculated at runtime
delay: 0, delay: 0, // calculated at runtime
dayOffset: 0, dayOffset: 0, // calculated at runtime
gap: 0, gap: 0, // calculated at runtime
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
custom: {}, custom: {},
}; };
export const delay: Omit<OntimeDelay, 'id'> = { export const delay: Omit<OntimeDelay, 'id'> = {
duration: 0,
type: SupportedEvent.Delay, type: SupportedEvent.Delay,
duration: 0,
}; };
export const block: Omit<OntimeBlock, 'id'> = { export const block: Omit<OntimeBlock, 'id'> = {
title: '',
type: SupportedEvent.Block, 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: {},
}; };
@@ -18,7 +18,7 @@ import {
import { dbModel } from '../../models/dataModel.js'; import { dbModel } from '../../models/dataModel.js';
import { deleteFile } from '../../utils/parserUtils.js'; import { deleteFile } from '../../utils/parserUtils.js';
import { parseDatabaseModel } from '../../utils/parser.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 { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js'; import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
@@ -40,6 +40,7 @@ import {
moveCorruptFile, moveCorruptFile,
parseJsonFile, parseJsonFile,
} from './projectServiceUtils.js'; } from './projectServiceUtils.js';
import { getFirstRundown } from '../rundown-service/rundownUtils.js';
// init dependencies // init dependencies
init(); init();
@@ -83,7 +84,7 @@ async function loadNewProject(): Promise<string> {
} }
/** /**
* 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 * Corrupted files in this context contain data that failed domain validation
*/ */
async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> { async function handleCorruptedFile(filePath: string, fileName: string): Promise<string> {
@@ -176,10 +177,11 @@ export async function loadProjectFile(name: string) {
// apply data model // apply data model
runtimeService.stop(); runtimeService.stop();
const { rundown, customFields } = result.data; const { rundowns, customFields } = result.data;
// apply the rundown // 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 // apply data model
runtimeService.stop(); runtimeService.stop();
const { rundown, customFields } = result.data; const { rundowns, customFields } = result.data;
// apply the rundown // 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<DatabaseModel>) {
runtimeService.stop(); runtimeService.stop();
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we need to remove the fields before merging // 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 // 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 // ... but rundown and custom fields need to be checked
if (rundown != null) { if (rundowns != null) {
const result = parseRundown(data); const result = parseRundowns(data);
await initRundown(result.rundown, result.customFields); /**
* 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;
} }
/** /**
@@ -44,12 +44,12 @@ describe('duplicateProjectFile', () => {
await expect(duplicateProjectFile('does not exist', 'doesnt matter')).rejects.toThrow('Project file not found'); 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 // current project exists
(doesProjectExist as Mock).mockReturnValueOnce('thisoneexists'); (doesProjectExist as Mock).mockReturnValueOnce('thisoneexists');
// new project exists // new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject'); (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', 'Project file with name existingproject already exists',
); );
}); });
@@ -66,7 +66,7 @@ describe('renameProjectFile', () => {
(doesProjectExist as Mock).mockReturnValueOnce('this one exists'); (doesProjectExist as Mock).mockReturnValueOnce('this one exists');
// new project exists // new project exists
(doesProjectExist as Mock).mockReturnValueOnce('existingproject'); (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', 'Project file with name existingproject already exists',
); );
}); });
@@ -4,13 +4,14 @@ import {
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEvent, OntimeEvent,
OntimeRundownEntry, OntimeEntry,
isOntimeBlock, isOntimeBlock,
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
OntimeRundown,
PatchWithId, PatchWithId,
EventPostPayload, EventPostPayload,
Rundown,
EntryId,
} from 'ontime-types'; } from 'ontime-types';
import { getCueCandidate } from 'ontime-utils'; import { getCueCandidate } from 'ontime-utils';
@@ -22,7 +23,6 @@ import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js'; import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
import { getPlayableEvents, getTimedEvents } from './rundownUtils.js';
type CompleteEntry<T> = type CompleteEntry<T> =
T extends Partial<OntimeEvent> T extends Partial<OntimeEvent>
@@ -33,15 +33,23 @@ type CompleteEntry<T> =
? OntimeBlock ? OntimeBlock
: never; : never;
/**
* Generates a fully formed RundownEntry of the patch type
*/
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>( function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
eventData: T, eventData: T,
afterId?: string, afterId?: string,
): CompleteEntry<T> { ): CompleteEntry<T> {
// TODO: could we keep the UI ID to avoid the flash on create?
// we discard any UI provided IDs and add our own // we discard any UI provided IDs and add our own
const id = cache.getUniqueId(); const id = cache.getUniqueId();
if (isOntimeEvent(eventData)) { if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(cache.getPersistedRundown(), afterId)) as CompleteEntry<T>; const currentRundown = cache.getCurrentRundown();
return createEvent(
eventData,
getCueCandidate(currentRundown.entries, currentRundown.order, afterId),
) as CompleteEntry<T>;
} }
if (isOntimeDelay(eventData)) { if (isOntimeDelay(eventData)) {
@@ -56,19 +64,17 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
} }
/** /**
* @description creates a new event with given data * creates a new event with given data
* @param {object} eventData
* @return {OntimeRundownEntry}
*/ */
export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundownEntry> { export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
// if the user didnt provide an index, we add the event to start // if the user didnt provide an index, we add the event to start
let atIndex = 0; let atIndex = 0;
let afterId: string | undefined = eventData?.after; let afterId: string | undefined = eventData?.after;
if (eventData?.after !== undefined) { if (afterId) {
const previousIndex = cache.getIndexOf(eventData.after); const previousIndex = cache.getIndexOf(afterId);
if (previousIndex < 0) { 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 { } else {
atIndex = previousIndex + 1; atIndex = previousIndex + 1;
} }
@@ -79,7 +85,7 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundo
} else { } else {
atIndex = previousIndex; atIndex = previousIndex;
if (previousIndex > 0) { if (previousIndex > 0) {
afterId = cache.getPersistedRundown()[atIndex - 1].id; afterId = cache.getIdOf(atIndex - 1);
} }
} }
} }
@@ -95,14 +101,14 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeRundo
updateRuntimeOnChange(); updateRuntimeOnChange();
// notify timer and external services of change // notify timer and external services of change
notifyChanges({ timer: [eventData.id], external: true }); notifyChanges({ timer: [eventToAdd.id], external: true });
return newEvent; // we know this mutation returns an OntimeEntry
return newEvent as OntimeEntry;
} }
/** /**
* deletes event by its ID * deletes event by its ID
* @param eventId
*/ */
export async function deleteEvent(eventIds: string[]) { export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove); const scopedMutation = cache.mutateCache(cache.remove);
@@ -194,9 +200,9 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
return reorderedItem; return reorderedItem;
} }
export async function applyDelay(eventId: string) { export async function applyDelay(delayId: EntryId) {
const scopedMutation = cache.mutateCache(cache.applyDelay); const scopedMutation = cache.mutateCache(cache.applyDelay);
await scopedMutation({ eventId }); await scopedMutation({ delayId });
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(); updateRuntimeOnChange();
@@ -227,8 +233,8 @@ export async function swapEvents(from: string, to: string) {
* Called when we make changes to the rundown object * Called when we make changes to the rundown object
*/ */
function updateRuntimeOnChange() { function updateRuntimeOnChange() {
const timedEvents = getTimedEvents(); const { timedEventsOrder } = cache.getEventOrder();
const numEvents = timedEvents.length; const numEvents = timedEventsOrder.length;
const metadata = cache.getMetadata(); const metadata = cache.getMetadata();
// schedule an update for the end of the event loop // schedule an update for the end of the event loop
@@ -251,9 +257,9 @@ type NotifyChangesOptions = {
*/ */
function notifyChanges(options: NotifyChangesOptions) { function notifyChanges(options: NotifyChangesOptions) {
if (options.timer) { if (options.timer) {
const playableEvents = getPlayableEvents(); const { playableEventsOrder } = cache.getEventOrder();
if (playableEvents.length === 0) { if (playableEventsOrder.length === 0) {
runtimeService.stop(); runtimeService.stop();
} else { } else {
// notify timer service of changed events // notify timer service of changed events
@@ -279,7 +285,7 @@ function notifyChanges(options: NotifyChangesOptions) {
* Overrides the rundown with the given * Overrides the rundown with the given
* @param rundown * @param rundown
*/ */
export async function initRundown(rundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) { export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
await cache.init(rundown, customFields); await cache.init(rundown, customFields);
// notify runtime that rundown has changed // notify runtime that rundown has changed
@@ -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 = { const baseEvent = {
type: SupportedEvent.Event, type: SupportedEvent.Event,
@@ -6,6 +7,10 @@ const baseEvent = {
revision: 1, revision: 1,
}; };
const baseBlock = {
type: SupportedEvent.Block,
};
/** /**
* Utility to create a Ontime event * Utility to create a Ontime event
*/ */
@@ -19,8 +24,25 @@ export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
/** /**
* Utility to create a delay event * Utility to create a delay event
*/ */
export function makeOntimeDelay(duration: number): OntimeDelay { export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration }; return { id: 'delay', type: SupportedEvent.Delay, duration: 0, ...patch } as OntimeDelay;
}
/**
* Utility to create a block event
*/
export function makeOntimeBlock(patch: Partial<OntimeBlock>): OntimeBlock {
return { id: 'block', ...baseBlock, ...patch } as OntimeBlock;
}
/**
* Utility to create a rundown object
*/
export function makeRundown(patch: Partial<Rundown>): Rundown {
return {
...defaultRundown,
...patch,
};
} }
/** /**
@@ -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 { MILLIS_PER_HOUR } from 'ontime-utils';
import { apply } from '../delayUtils.js'; 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()', () => { describe('apply()', () => {
it('applies a positive delay to the rundown', () => { it('applies a positive delay to the rundown', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeDelay(10), revision: 0,
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), order: ['delay', '1', '2', '3', '4', '5'],
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), entries: {
{ id: '3', type: SupportedEvent.Block } as OntimeBlock, delay: makeOntimeDelay({ id: 'delay', duration: 10 }),
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), '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); apply('delay', testRundown);
expect(updatedRundown).not.toBe(testRundown); expect(testRundown.revision).toBe(1);
expect(updatedRundown).toMatchObject([ expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
{ id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 }, expect(testRundown.entries).toMatchObject({
{ id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' }, '1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
{ id: '3' }, '2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '1' },
{ id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: null }, '3': { id: '3' },
{ id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: '4' }, '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', () => { it('applies negative delays', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeDelay(-10), revision: 0,
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }), order: ['delay', '1', '2', '3', '4', '5'],
makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: '1' }), entries: {
{ id: '3', type: SupportedEvent.Block } as OntimeBlock, delay: makeOntimeDelay({ id: 'delay', duration: -10 }),
makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: null }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: '4' }), '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); apply('delay', testRundown);
expect(updatedRundown).toMatchObject([ expect(testRundown.revision).toBe(1);
{ id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 }, expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
{ id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null }, expect(testRundown.entries).toMatchObject({
{ id: '3' }, '1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
{ id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: null }, '2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: null },
{ id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: '4' }, '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', () => { it('should account for minimum duration and start when applying negative delays', () => {
const testRundown: OntimeRundown = [ const testRundown = makeRundown({
makeOntimeDelay(-50), order: ['delay', '1', '2'],
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), entries: {
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: '1' }), 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 = [ apply('delay', testRundown);
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 2 } as OntimeEvent, 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', id: '2',
type: SupportedEvent.Event, type: SupportedEvent.Event,
timeStart: 50, timeStart: 50,
@@ -63,173 +86,222 @@ describe('apply()', () => {
duration: 50, duration: 50,
linkStart: null, linkStart: null,
revision: 2, revision: 2,
} as OntimeEvent, },
]; });
const updatedRundown = apply('delay', testRundown);
expect(updatedRundown).toMatchObject(expected);
}); });
it('unlinks events to maintain gaps when applying positive delays', () => { it('unlinks events to maintain gaps when applying positive delays', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), order: ['1', 'delay', '2'],
makeOntimeDelay(50), entries: {
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), '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([ apply('delay', testRundown);
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, expect(testRundown.order).toMatchObject(['1', '2']);
{ expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
timeStart: 0,
timeEnd: 100,
duration: 100,
revision: 1,
},
'2': {
id: '2', id: '2',
type: SupportedEvent.Event,
timeStart: 150, timeStart: 150,
timeEnd: 200, timeEnd: 200,
duration: 50, duration: 50,
linkStart: null, linkStart: null,
revision: 2, revision: 2,
} as OntimeEvent, },
]); });
}); });
it('maintains links if there is no gap', () => { it('maintains links if there is no gap', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeDelay(50), order: ['delay', '1', '2'],
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), entries: {
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), 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([ apply('delay', testRundown);
{ id: '1', type: SupportedEvent.Event, timeStart: 50, timeEnd: 150, duration: 100, revision: 2 } as OntimeEvent, expect(testRundown.order).toMatchObject(['1', '2']);
{ expect(testRundown.entries).toMatchObject({
'1': {
id: '1',
timeStart: 50,
timeEnd: 150,
duration: 100,
revision: 2,
},
'2': {
id: '2', id: '2',
type: SupportedEvent.Event,
timeStart: 150, timeStart: 150,
timeEnd: 200, timeEnd: 200,
duration: 50, duration: 50,
linkStart: '1', linkStart: '1',
revision: 2, revision: 2,
} as OntimeEvent, },
]); });
}); });
it('unlinks events to maintain gaps when applying negative delays', () => { it('unlinks events to maintain gaps when applying negative delays', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), order: ['1', 'delay', '2'],
makeOntimeDelay(-50), entries: {
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), '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([ apply('delay', testRundown);
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, expect(testRundown.order).toMatchObject(['1', '2']);
{ expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 },
'2': {
id: '2', id: '2',
type: SupportedEvent.Event,
timeStart: 50, timeStart: 50,
timeEnd: 100, timeEnd: 100,
duration: 50, duration: 50,
linkStart: null, linkStart: null,
revision: 2, revision: 2,
} as OntimeEvent, },
]); });
}); });
it('gaps reduce positive delay', () => { it('gaps reduce positive delay', () => {
const testRundown: OntimeRundown = [ const testRundown = makeRundown({
makeOntimeDelay(100), order: ['delay', '1', '2', '3', '4', '5'],
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), entries: {
// gap 50 delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }), '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 0 // gap 50
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }), '2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
// gap 50 // gap 0
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }), '3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
// linked // gap 50
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }), '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); apply('delay', testRundown);
expect(updatedRundown).toMatchObject([ expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
{ id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 }, expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
// gap 50 (100 - 50) // 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) // 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) // 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 // 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)', () => { it('gaps reduce positive delay (2)', () => {
const testRundown: OntimeRundown = [ const testRundown = makeRundown({
makeOntimeDelay(2 * MILLIS_PER_HOUR), order: ['delay', '1', '2'],
makeOntimeEvent({ entries: {
id: '1', delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }),
gap: 0, '1': makeOntimeEvent({
dayOffset: 0, id: '1',
timeStart: 46800000, // 13:00:00 gap: 0,
timeEnd: 50400000, // 14:00:00 dayOffset: 0,
duration: MILLIS_PER_HOUR, timeStart: 46800000, // 13:00:00
}), timeEnd: 50400000, // 14:00:00
// gap 1h duration: MILLIS_PER_HOUR,
makeOntimeEvent({ }),
id: '2', // gap 1h
gap: 1 * MILLIS_PER_HOUR, '2': makeOntimeEvent({
dayOffset: 0, id: '2',
timeStart: 54000000, // 15:00:00 gap: 1 * MILLIS_PER_HOUR,
timeEnd: 57600000, // 16:00:00 dayOffset: 0,
duration: MILLIS_PER_HOUR, timeStart: 54000000, // 15:00:00
}), timeEnd: 57600000, // 16:00:00
]; duration: MILLIS_PER_HOUR,
}),
},
});
const updatedRundown = apply('delay', testRundown); apply('delay', testRundown);
expect(updatedRundown).toMatchObject([ expect(testRundown.order).toMatchObject(['1', '2']);
{ id: '1', timeStart: 54000000 /* 16 */, revision: 2 }, expect(testRundown.entries).toMatchObject({
'1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
// gap 1h (2h - 1h) // 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', () => { it('removes empty delays without applying changes', () => {
const testRundown: OntimeRundown = [ const testRundown = makeRundown({
makeOntimeDelay(0), order: ['delay', '1'],
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), entries: {
]; delay: makeOntimeDelay({ id: 'delay', duration: 0 }),
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
},
});
const updatedRundown = apply('delay', testRundown); apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); 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', () => { it('removes delays in last position without applying changes', () => {
const testRundown: OntimeRundown = [ const testRundown = makeRundown({
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }), order: ['1', 'delay'],
makeOntimeDelay(100), entries: {
]; '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
},
});
const updatedRundown = apply('delay', testRundown); apply('delay', testRundown);
expect(updatedRundown).toMatchObject([{ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }]); 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', () => { it('unlinks events to across blocks is it is the first event after the delay', () => {
const testRundown = [ const testRundown = makeRundown({
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }), order: ['1', 'delay', 'block', '2'],
makeOntimeDelay(50), entries: {
{ id: 'block', type: SupportedEvent.Block } as OntimeBlock, '1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }), delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
]; block: makeOntimeBlock({ id: 'block' }),
expect(apply('delay', testRundown)).toMatchObject([ '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: '1' }),
{ id: '1', type: SupportedEvent.Event, timeStart: 0, timeEnd: 100, duration: 100, revision: 1 } as OntimeEvent, },
{ id: 'block', type: SupportedEvent.Block }, });
{
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', id: '2',
type: SupportedEvent.Event,
timeStart: 150, timeStart: 150,
timeEnd: 200, timeEnd: 200,
duration: 50, duration: 50,
linkStart: null, linkStart: null,
revision: 2, revision: 2,
} as OntimeEvent, },
]); });
}); });
}); });
@@ -1,13 +1,4 @@
import { import { CustomFields, OntimeEvent, SupportedEvent, TimeStrategy } from 'ontime-types';
CustomFields,
EventCustomFields,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimeStrategy,
} from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils'; import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { import {
@@ -23,6 +14,7 @@ import {
removeCustomField, removeCustomField,
customFieldChangelog, customFieldChangelog,
} from '../rundownCache.js'; } from '../rundownCache.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
beforeAll(() => { beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => { vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -39,13 +31,16 @@ beforeAll(() => {
describe('generate()', () => { describe('generate()', () => {
it('creates normalised versions of a given rundown', () => { it('creates normalised versions of a given rundown', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent, order: ['1', '2', '3'],
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock, entries: {
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay, '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.length).toBe(3);
expect(initResult.order).toStrictEqual(['1', '2', '3']); expect(initResult.order).toStrictEqual(['1', '2', '3']);
expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event); expect(initResult.rundown['1'].type).toBe(SupportedEvent.Event);
@@ -54,29 +49,35 @@ describe('generate()', () => {
}); });
it('calculates delays versions of a given rundown', () => { it('calculates delays versions of a given rundown', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Delay, id: '1', duration: 100 } as OntimeDelay, order: ['1', '2'],
{ type: SupportedEvent.Event, id: '2', timeStart: 1, timeEnd: 100 } as OntimeEvent, 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.order.length).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(100);
expect(initResult.totalDelay).toBe(100); expect(initResult.totalDelay).toBe(100);
}); });
it('accounts for gaps in rundown when calculating delays', () => { it('accounts for gaps in rundown when calculating delays', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'],
{ type: SupportedEvent.Delay, id: 'delay', duration: 200 } as OntimeDelay, entries: {
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, delay: makeOntimeDelay({ id: 'delay', duration: 200 }),
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, block: makeOntimeBlock({ id: 'block', title: 'break' }),
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, '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.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(200);
@@ -87,76 +88,84 @@ describe('generate()', () => {
}); });
it('accounts for overlaps in rundown', () => { it('accounts for overlaps in rundown', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, order: ['1', '2', '3'],
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, entries: {
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, '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 expect(initResult.totalDuration).toBe(10500 - 9000); // last end - first start
}); });
it('accounts for overlaps in rundown (with added gap)', () => { it('accounts for overlaps in rundown (with added gap)', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 } as OntimeEvent, order: ['1', '2', '3', '4'],
{ type: SupportedEvent.Event, id: '2', timeStart: 9250, timeEnd: 9500, duration: 250 } as OntimeEvent, entries: {
{ type: SupportedEvent.Event, id: '3', timeStart: 9500, timeEnd: 10500, duration: 1000 } as OntimeEvent, '1': makeOntimeEvent({ id: '1', timeStart: 9000, timeEnd: 10000, duration: 1000 }),
{ type: SupportedEvent.Event, id: '4', timeStart: 15000, timeEnd: 20000, duration: 5000 } as OntimeEvent, '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 expect(initResult.totalDuration).toBe(20000 - 9000); // last end - first start
}); });
it('accounts for overlaps in rundown (with multiple days)', () => { it('accounts for overlaps in rundown (with multiple days)', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ order: ['1', '2', '3', '4'],
type: SupportedEvent.Event, entries: {
id: '1', '1': makeOntimeEvent({
timeStart: 9 * MILLIS_PER_HOUR, id: '1',
timeEnd: 10 * MILLIS_PER_HOUR, timeStart: 9 * MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR, timeEnd: 10 * MILLIS_PER_HOUR,
} as OntimeEvent, duration: MILLIS_PER_HOUR,
{ }),
type: SupportedEvent.Event, '2': makeOntimeEvent({
id: '2', id: '2',
timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE, timeStart: 9 * MILLIS_PER_HOUR + 15 * MILLIS_PER_MINUTE,
timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE, timeEnd: 9 * MILLIS_PER_HOUR + 45 * MILLIS_PER_MINUTE,
duration: 30 * MILLIS_PER_MINUTE, duration: 30 * MILLIS_PER_MINUTE,
} as OntimeEvent, }),
{ '3': makeOntimeEvent({
type: SupportedEvent.Event, id: '3',
id: '3', timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
timeStart: 9 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE,
timeEnd: 10 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, duration: MILLIS_PER_HOUR,
duration: MILLIS_PER_HOUR, }),
} as OntimeEvent, '4': makeOntimeEvent({
{ id: '4',
type: SupportedEvent.Event, timeStart: 9 * MILLIS_PER_HOUR,
id: '4', timeEnd: 10 * MILLIS_PER_HOUR,
timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR,
timeEnd: 10 * MILLIS_PER_HOUR, }),
duration: MILLIS_PER_HOUR, },
} as OntimeEvent, });
];
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start expect(initResult.totalDuration).toBe(dayInMs + MILLIS_PER_HOUR); // day + last end - first start
}); });
it('handles negative delays', () => { it('handles negative delays', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, order: ['1', 'delay', '2', 'block', '3', 'another-block', '4'],
{ type: SupportedEvent.Delay, id: 'delay', duration: -200 } as OntimeDelay, entries: {
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
{ type: SupportedEvent.Block, id: 'block', title: 'break' } as OntimeBlock, delay: makeOntimeDelay({ id: 'delay', duration: -200 }),
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent, '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
{ type: SupportedEvent.Block, id: 'another-block', title: 'another-break' } as OntimeBlock, block: makeOntimeBlock({ id: 'block', title: 'break' }),
{ type: SupportedEvent.Event, id: '4', timeStart: 600, timeEnd: 700, duration: 100 } as OntimeEvent, '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.order.length).toBe(7);
expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0); expect((initResult.rundown['1'] as OntimeEvent).delay).toBe(0);
expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200); expect((initResult.rundown['2'] as OntimeEvent).delay).toBe(-200);
@@ -167,38 +176,38 @@ describe('generate()', () => {
}); });
it('links times across events', () => { it('links times across events', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ order: ['1', '2', 'block', 'delay', '3'],
type: SupportedEvent.Event, entries: {
id: '1', '1': makeOntimeEvent({
timeStart: 1, id: '1',
duration: 1, timeStart: 1,
timeEnd: 2, duration: 1,
timeStrategy: TimeStrategy.LockEnd, timeEnd: 2,
} as OntimeEvent, timeStrategy: TimeStrategy.LockEnd,
{ }),
type: SupportedEvent.Event, '2': makeOntimeEvent({
id: '2', id: '2',
timeStart: 11, timeStart: 11,
duration: 1, duration: 1,
timeEnd: 12, timeEnd: 12,
linkStart: '1', linkStart: '1',
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
} as OntimeEvent, }),
{ type: SupportedEvent.Block, id: 'block' } as OntimeBlock, block: makeOntimeBlock({ id: 'block' }),
{ type: SupportedEvent.Delay, id: 'delay' } as OntimeDelay, delay: makeOntimeDelay({ id: 'delay' }),
{ '3': makeOntimeEvent({
type: SupportedEvent.Event, id: '3',
id: '3', timeStart: 21,
timeStart: 21, duration: 1,
duration: 1, timeEnd: 22,
timeEnd: 22, linkStart: '2',
linkStart: '2', timeStrategy: TimeStrategy.LockEnd,
timeStrategy: TimeStrategy.LockEnd, }),
} as OntimeEvent, },
]; });
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.order.length).toBe(5); expect(initResult.order.length).toBe(5);
expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2); expect((initResult.rundown['2'] as OntimeEvent).timeStart).toBe(2);
expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12); expect((initResult.rundown['2'] as OntimeEvent).timeEnd).toBe(12);
@@ -213,13 +222,16 @@ describe('generate()', () => {
}); });
it('links times across events, reordered', () => { it('links times across events, reordered', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 1, timeEnd: 2 } as OntimeEvent, order: ['1', '3', '2'],
{ type: SupportedEvent.Event, id: '3', timeStart: 21, timeEnd: 22, linkStart: '2' } as OntimeEvent, entries: {
{ type: SupportedEvent.Event, id: '2', timeStart: 11, timeEnd: 12, linkStart: '1' } as OntimeEvent, '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.order.length).toBe(3);
expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2); expect((initResult.rundown['3'] as OntimeEvent).timeStart).toBe(2);
expect(initResult.links['1']).toBe('3'); expect(initResult.links['1']).toBe('3');
@@ -227,159 +239,156 @@ describe('generate()', () => {
}); });
it('calculates total duration', () => { it('calculates total duration', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200, duration: 100 } as OntimeEvent, order: ['1', '2', 'skipped', '3'],
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300, duration: 100 } as OntimeEvent, entries: {
{ '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 200, duration: 100 }),
type: SupportedEvent.Event, '2': makeOntimeEvent({ id: '2', timeStart: 200, timeEnd: 300, duration: 100 }),
id: 'skipped', skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 100 }),
skip: true, '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }),
timeStart: 300, },
timeEnd: 400, });
duration: 100,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
];
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.order.length).toBe(4); expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100); expect(initResult.totalDuration).toBe(500 - 100);
}); });
it('calculates total duration with 0 duration events without causing a next day', () => { it('calculates total duration with 0 duration events without causing a next day', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 100, duration: 0 } as OntimeEvent, order: ['1', '2', 'skipped', '3'],
{ type: SupportedEvent.Event, id: '2', timeStart: 100, timeEnd: 300, duration: 200 } as OntimeEvent, entries: {
{ '1': makeOntimeEvent({ id: '1', timeStart: 100, timeEnd: 100, duration: 0 }),
type: SupportedEvent.Event, '2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 300, duration: 200 }),
id: 'skipped', skipped: makeOntimeEvent({ id: 'skipped', skip: true, timeStart: 300, timeEnd: 400, duration: 0 }),
skip: true, '3': makeOntimeEvent({ id: '2', timeStart: 400, timeEnd: 500, duration: 100 }),
timeStart: 300, },
timeEnd: 400, });
duration: 0,
} as OntimeEvent,
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500, duration: 100 } as OntimeEvent,
];
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.order.length).toBe(4); expect(initResult.order.length).toBe(4);
expect(initResult.totalDuration).toBe(500 - 100); expect(initResult.totalDuration).toBe(500 - 100);
}); });
it('calculates total duration across days with gap', () => { it('calculates total duration across days with gap', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ order: ['1', '2', '3'],
type: SupportedEvent.Event, entries: {
id: '1', '1': makeOntimeEvent({
timeStart: 9 * MILLIS_PER_HOUR, id: '1',
timeEnd: 23 * MILLIS_PER_HOUR, timeStart: 9 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR, timeEnd: 23 * MILLIS_PER_HOUR,
} as OntimeEvent, duration: (23 - 9) * MILLIS_PER_HOUR,
{ }),
type: SupportedEvent.Event, '2': makeOntimeEvent({
id: '2', id: '2',
timeStart: 9 * MILLIS_PER_HOUR, timeStart: 9 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR, timeEnd: 23 * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR, duration: (23 - 9) * MILLIS_PER_HOUR,
} as OntimeEvent, }),
{ '3': makeOntimeEvent({
type: SupportedEvent.Event, id: '2',
id: '3', timeStart: 9 * MILLIS_PER_HOUR,
timeStart: 9 * MILLIS_PER_HOUR, timeEnd: 23 * MILLIS_PER_HOUR,
timeEnd: 23 * MILLIS_PER_HOUR, duration: (23 - 9) * MILLIS_PER_HOUR,
duration: (23 - 9) * MILLIS_PER_HOUR, }),
} as OntimeEvent, },
]; });
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR); expect(initResult.totalDuration).toBe((23 - 9 + 48) * MILLIS_PER_HOUR);
}); });
it('calculates total duration across days', () => { it('calculates total duration across days', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ order: ['1', '2'],
type: SupportedEvent.Event, entries: {
id: '1', '1': makeOntimeEvent({
timeStart: 12 * MILLIS_PER_HOUR, id: '1',
timeEnd: 22 * MILLIS_PER_HOUR, timeStart: 12 * MILLIS_PER_HOUR,
duration: 10 * MILLIS_PER_HOUR, timeEnd: 22 * MILLIS_PER_HOUR,
} as OntimeEvent, duration: 10 * MILLIS_PER_HOUR,
{ }),
type: SupportedEvent.Event, '2': makeOntimeEvent({
id: '2', id: '2',
timeStart: 22 * MILLIS_PER_HOUR, timeStart: 22 * MILLIS_PER_HOUR,
timeEnd: 8 * MILLIS_PER_HOUR, timeEnd: 8 * MILLIS_PER_HOUR,
duration: (24 - 22 + 8) * MILLIS_PER_HOUR, duration: (24 - 22 + 8) * MILLIS_PER_HOUR,
} as OntimeEvent, }),
]; },
});
const initResult = generate(testRundown); const initResult = generate(rundown);
const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR); const expectedDuration = 8 * MILLIS_PER_HOUR + (dayInMs - 12 * MILLIS_PER_HOUR);
expect(initResult.totalDuration).toBe(expectedDuration); expect(initResult.totalDuration).toBe(expectedDuration);
}); });
it('handles updating event sequence', () => { it('handles updating event sequence', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ order: ['1', '2', '3'],
type: SupportedEvent.Event, entries: {
id: '97cc3e', '1': makeOntimeEvent({
timeStart: 0, id: '1',
timeEnd: 600000, timeStart: 0,
duration: 600000, timeEnd: 600000,
timeStrategy: TimeStrategy.LockDuration, duration: 600000,
linkStart: null, timeStrategy: TimeStrategy.LockDuration,
} as OntimeEvent, linkStart: null,
{ }),
type: SupportedEvent.Event, '2': makeOntimeEvent({
id: 'e01948', id: '2',
timeStart: 600000, timeStart: 600000,
timeEnd: 601000, timeEnd: 601000,
duration: 85801000, // <------------- value out of sync duration: 85801000, // <------------- value out of sync
timeStrategy: TimeStrategy.LockEnd, timeStrategy: TimeStrategy.LockEnd,
linkStart: '97cc3e', linkStart: '1',
} as OntimeEvent, }),
{ '3': makeOntimeEvent({
type: SupportedEvent.Event, id: '3',
id: '25c1af', timeStart: 100, // <------------- value out of sync
timeStart: 100, // <------------- value out of sync timeEnd: 602000,
timeEnd: 602000, duration: 0,
duration: 0, timeStrategy: TimeStrategy.LockEnd,
timeStrategy: TimeStrategy.LockEnd, linkStart: '2',
linkStart: 'e01948', }),
} as OntimeEvent, },
]; });
const initResult = generate(testRundown); const initResult = generate(rundown);
expect(initResult.rundown).toMatchObject({ expect(initResult.rundown).toMatchObject({
'97cc3e': { '1': {
timeStart: 0, timeStart: 0,
timeEnd: 600000, timeEnd: 600000,
duration: 600000, duration: 600000,
timeStrategy: 'lock-duration', timeStrategy: 'lock-duration',
linkStart: null, linkStart: null,
}, },
e01948: { '2': {
timeStart: 600000, timeStart: 600000,
timeEnd: 601000, timeEnd: 601000,
duration: 1000, duration: 1000,
timeStrategy: 'lock-end', timeStrategy: 'lock-end',
linkStart: '97cc3e', linkStart: '1',
}, },
'25c1af': { '3': {
timeStart: 601000, timeStart: 601000,
timeEnd: 602000, timeEnd: 602000,
duration: 1000, duration: 1000,
timeStrategy: 'lock-end', timeStrategy: 'lock-end',
linkStart: 'e01948', linkStart: '2',
}, },
}); });
}); });
it('deletes links if invalid', () => { it('deletes links if invalid', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1', timeStart: 1, linkStart: '10' } as OntimeEvent, order: ['1'],
]; entries: {
const initResult = generate(testRundown); '1': makeOntimeEvent({ id: '1', timeStart: 1, linkStart: '10' }),
},
});
const initResult = generate(rundown);
expect(initResult.order.length).toBe(1); expect(initResult.order.length).toBe(1);
expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1); expect((initResult.rundown['1'] as OntimeEvent).timeStart).toBe(1);
expect(Object.keys(initResult.links).length).toBe(0); expect(Object.keys(initResult.links).length).toBe(0);
@@ -399,24 +408,26 @@ describe('generate()', () => {
colour: 'red', colour: 'red',
}, },
}; };
const testRundown: OntimeRundown = [
{ const rundown = makeRundown({
type: SupportedEvent.Event, order: ['1', '2'],
id: '1', entries: {
custom: { '1': makeOntimeEvent({
lighting: 'event 1 lx', id: '1',
} as EventCustomFields, custom: {
} as OntimeEvent, lighting: 'event 1 lx',
{ },
type: SupportedEvent.Event, }),
id: '2', '2': makeOntimeEvent({
custom: { id: '2',
lighting: 'event 2 lx', custom: {
sound: 'event 2 sound', lighting: 'event 2 lx',
} as EventCustomFields, sound: 'event 2 sound',
} as OntimeEvent, },
]; }),
const initResult = generate(testRundown, customProperties); },
});
const initResult = generate(rundown, customProperties);
expect(initResult.order.length).toBe(2); expect(initResult.order.length).toBe(2);
expect(initResult.assignedCustomFields).toMatchObject({ expect(initResult.assignedCustomFields).toMatchObject({
lighting: ['1', '2'], lighting: ['1', '2'],
@@ -433,47 +444,64 @@ describe('generate()', () => {
describe('add() mutation', () => { describe('add() mutation', () => {
test('adds an event to the rundown', () => { test('adds an event to the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const testRundown: OntimeRundown = []; const rundown = makeRundown({});
const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown }); const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown });
expect(newRundown.length).toBe(1); expect(newRundown.order.length).toBe(1);
expect(newRundown[0]).toMatchObject(mockEvent); expect(newRundown.entries['mock']).toMatchObject(mockEvent);
}); });
}); });
describe('remove() mutation', () => { describe('remove() mutation', () => {
test('deletes an event from the rundown', () => { test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const testRundown: OntimeRundown = [mockEvent]; const rundown = makeRundown({
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown }); order: ['mock'],
expect(newRundown.length).toBe(0); entries: {
mock: mockEvent,
},
});
const { newRundown } = remove({ eventIds: [mockEvent.id], rundown });
expect(newRundown.order.length).toBe(0);
}); });
test('deletes multiple events from the rundown', () => { test('deletes multiple events from the rundown', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent, order: ['1', '2', '3', '4', '5', '6'],
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock, entries: {
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay, '1': makeOntimeEvent({ id: '1' }),
{ type: SupportedEvent.Event, id: '4' } as OntimeEvent, '2': makeOntimeBlock({ id: '2' }),
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent, '3': makeOntimeDelay({ id: '3' }),
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent, '4': makeOntimeEvent({ id: '4' }),
]; '5': makeOntimeEvent({ id: '5' }),
const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown }); '6': makeOntimeEvent({ id: '6' }),
expect(newRundown.length).toBe(3); },
expect(newRundown.at(0)?.id).toBe('4'); });
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', () => { describe('edit() mutation', () => {
test('edits an event in the rundown', () => { test('edits an event in the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const mockEventPatch = { cue: 'patched' } as OntimeEvent; const mockEventPatch = makeOntimeEvent({ cue: 'patched' });
const testRundown: OntimeRundown = [mockEvent]; const rundown = makeRundown({
order: ['mock'],
entries: {
mock: mockEvent,
},
});
const { newRundown, newEvent } = edit({ const { newRundown, newEvent } = edit({
eventId: mockEvent.id, eventId: mockEvent.id,
patch: mockEventPatch, patch: mockEventPatch,
rundown: testRundown, rundown,
}); });
expect(newRundown.length).toBe(1); expect(newRundown.order.length).toBe(1);
expect(newEvent).toMatchObject({ expect(newEvent).toMatchObject({
id: 'mock', id: 'mock',
cue: 'patched', cue: 'patched',
@@ -484,73 +512,96 @@ describe('edit() mutation', () => {
describe('batchEdit() mutation', () => { describe('batchEdit() mutation', () => {
it('should correctly apply the patch to the events with the given IDs', () => { it('should correctly apply the patch to the events with the given IDs', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent, order: ['1', '2', '3'],
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent, entries: {
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent, '1': makeOntimeEvent({ id: '1', cue: 'data1' }),
]; '2': makeOntimeEvent({ id: '2', cue: 'data2' }),
'3': makeOntimeEvent({ id: '3', cue: 'data3' }),
},
});
const eventIds = ['1', '3']; const eventIds = ['1', '3'];
const patch = { cue: 'newData' }; const patch = { cue: 'newData' };
const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch }); const { newRundown } = batchEdit({ rundown, eventIds, patch });
expect(newRundown).toMatchObject([ expect(newRundown.entries).toMatchObject({
{ id: '1', type: SupportedEvent.Event, cue: 'newData' }, '1': { id: '1', type: SupportedEvent.Event, cue: 'newData' },
{ id: '2', type: SupportedEvent.Event, cue: 'data2' }, '2': { id: '2', type: SupportedEvent.Event, cue: 'data2' },
{ id: '3', type: SupportedEvent.Event, cue: 'newData' }, '3': { id: '3', type: SupportedEvent.Event, cue: 'newData' },
]); });
}); });
}); });
describe('reorder() mutation', () => { describe('reorder() mutation', () => {
it('should correctly reorder two events', () => { it('should correctly reorder two events', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent, order: ['1', '2', '3'],
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent, entries: {
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent, '1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }),
]; '2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }),
const { newRundown } = reorder({ '3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }),
rundown: testRundown, },
eventId: testRundown[0].id,
from: 0,
to: testRundown.length - 1,
}); });
expect(newRundown).toMatchObject([ // move first event to the end
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 1 }, const { newRundown } = reorder({
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 1 }, rundown: rundown,
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 1 }, 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', () => { describe('swap() mutation', () => {
it('should correctly swap data between events', () => { it('should correctly swap data between events', () => {
const testRundown: OntimeRundown = [ const rundown = makeRundown({
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent, order: ['1', '2', '3'],
{ id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent, entries: {
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent, '1': makeOntimeEvent({ id: '1', cue: 'data1', timeStart: 1, revision: 4 }),
]; '2': makeOntimeEvent({ id: '2', cue: 'data2', timeStart: 2, revision: 8 }),
const { newRundown } = swap({ '3': makeOntimeEvent({ id: '3', cue: 'data3', timeStart: 3, revision: 12 }),
rundown: testRundown, },
fromId: testRundown[0].id,
toId: testRundown[1].id,
}); });
expect((newRundown[0] as OntimeEvent).id).toBe('1'); // swap first and second event
expect((newRundown[0] as OntimeEvent).cue).toBe('data2'); const { newRundown } = swap({
expect((newRundown[0] as OntimeEvent).timeStart).toBe(1); rundown: rundown,
expect((newRundown[0] as OntimeEvent).revision).toBe(1); fromId: rundown.order[0],
toId: rundown.order[1],
});
expect((newRundown[1] as OntimeEvent).id).toBe('2'); expect(newRundown.order).toStrictEqual(['1', '2', '3']);
expect((newRundown[1] as OntimeEvent).cue).toBe('data1');
expect((newRundown[1] as OntimeEvent).timeStart).toBe(2);
expect((newRundown[1] as OntimeEvent).revision).toBe(1);
expect((newRundown[2] as OntimeEvent).id).toBe('3'); expect(newRundown.entries['1']).toMatchObject({
expect((newRundown[2] as OntimeEvent).cue).toBe('data3'); id: '1',
expect((newRundown[2] as OntimeEvent).timeStart).toBe(3); cue: 'data2',
expect((newRundown[2] as OntimeEvent).revision).toBe(0); 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,
});
}); });
}); });
@@ -2,7 +2,7 @@ import {
CustomFields, CustomFields,
EndAction, EndAction,
OntimeEvent, OntimeEvent,
OntimeRundown, RundownEntries,
SupportedEvent, SupportedEvent,
TimeStrategy, TimeStrategy,
TimerType, TimerType,
@@ -10,46 +10,25 @@ import {
import { import {
addToCustomAssignment, addToCustomAssignment,
calculateDayOffset, calculateDayOffset,
getLink,
handleCustomField, handleCustomField,
handleLink, handleLink,
hasChanges, hasChanges,
isDataStale, isDataStale,
} from '../rundownCacheUtils.js'; } from '../rundownCacheUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils'; import { MILLIS_PER_HOUR } from 'ontime-utils';
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
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');
});
});
describe('handleLink()', () => { describe('handleLink()', () => {
it('populates data in object and updates link map', () => { it('populates data in object and updates link map', () => {
const rundown = [ const entries: RundownEntries = {
{ type: SupportedEvent.Event, id: '1', timeEnd: 100 }, '1': makeOntimeEvent({ id: '1', timeEnd: 100 }),
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
] as OntimeRundown; };
const mutableEvent = { ...rundown[1] } as OntimeEvent;
const mutableEvent = { ...entries[2] } as OntimeEvent;
const links = {}; const links = {};
const result = handleLink(1, rundown, mutableEvent, links); const result = handleLink(mutableEvent, entries[1] as OntimeEvent, links);
expect(result).toBeUndefined(); expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(100); expect(mutableEvent.timeStart).toBe(100);
expect(mutableEvent.linkStart).toBe('1'); expect(mutableEvent.linkStart).toBe('1');
@@ -57,17 +36,17 @@ describe('handleLink()', () => {
}); });
it('removes link if linked event is not found', () => { it('removes link if linked event is not found', () => {
const rundown = [ const entries: RundownEntries = {
{ type: SupportedEvent.Block, id: '1' }, '1': makeOntimeBlock({ id: '1' }),
{ type: SupportedEvent.Event, id: '2', timeStart: 0, linkStart: '1' }, '2': makeOntimeEvent({ id: '2', timeStart: 0, linkStart: '1' }),
] as OntimeRundown; };
const mutableEvent = { ...rundown[1] } as OntimeEvent; const mutableEvent = { ...entries[2] } as OntimeEvent;
const links = {}; const links = {};
const result = handleLink(1, rundown, mutableEvent, links); const result = handleLink(mutableEvent, null, links);
expect(result).toBeUndefined(); expect(result).toBeUndefined();
expect(mutableEvent.timeStart).toBe(0); expect(mutableEvent.timeStart).toBe(0);
expect(mutableEvent.linkStart).toBe(null); expect(mutableEvent.linkStart).toBe('true');
expect(links).toStrictEqual({}); expect(links).toStrictEqual({});
}); });
}); });
@@ -252,7 +231,7 @@ describe('hasChanges()', () => {
describe('calculateDayOffset', () => { describe('calculateDayOffset', () => {
it('returns 0 if there is no previous event', () => { 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', () => { it('returns 0 if the previous event duration is 0', () => {
@@ -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'; import { deleteAtIndex } from 'ontime-utils';
/** /**
* Applies delay from given event ID, deletes the delay event after * 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 { export function apply(delayId: EntryId, rundown: Rundown): Rundown {
const delayIndex = rundown.findIndex((event) => event.id === eventId); const delayEvent = rundown.entries[delayId];
const delayEvent = rundown.at(delayIndex);
if (!delayEvent) { if (!delayEvent || !isOntimeDelay(delayEvent)) {
throw new Error('Given event ID not found'); throw new Error('Given delay ID not found');
} }
if (!isOntimeDelay(delayEvent)) { const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId);
throw new Error('Given event ID is not a delay');
}
// if the delay is empty, or the last element, we can just delete it // if the delay is empty, or the last element
if (delayEvent.duration === 0 || delayIndex === rundown.length - 1) { // we can just delete it with no further operations
return deleteAtIndex(delayIndex, rundown); 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 * We apply the delay to the rundown
* This logic is mostly in sync with rundownCache.generate * 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 delayValue = delayEvent.duration;
let lastEntry: OntimeEvent | null = null; let lastEntry: OntimeEvent | null = null;
let isFirstEvent = true; let isFirstEvent = true;
for (let i = delayIndex + 1; i < updatedRundown.length; i++) { for (let i = delayIndex + 1; i < rundown.order.length; i++) {
const currentEntry = updatedRundown[i]; const currentId = rundown.order[i];
const currentEntry = rundown.entries[currentId];
// we don't do operation on other event types // we don't do operation on other event types
if (!isOntimeEvent(currentEntry)) { if (!isOntimeEvent(currentEntry)) {
@@ -77,5 +80,9 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
currentEntry.revision += 1; currentEntry.revision += 1;
} }
return deleteAtIndex(delayIndex, updatedRundown); delete rundown.entries[delayId];
rundown.order = deleteAtIndex(delayIndex, rundown.order);
rundown.revision += 1;
return rundown;
} }
@@ -2,15 +2,18 @@ import {
CustomField, CustomField,
CustomFieldLabel, CustomFieldLabel,
CustomFields, CustomFields,
EntryId,
isOntimeBlock, isOntimeBlock,
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
MaybeNumber, MaybeNumber,
OntimeBlock,
OntimeEvent, OntimeEvent,
OntimeRundown, OntimeEntry,
OntimeRundownEntry,
PlayableEvent, PlayableEvent,
Rundown,
RundownEntries,
} from 'ontime-types'; } from 'ontime-types';
import { import {
generateId, generateId,
@@ -21,26 +24,32 @@ import {
isNewLatest, isNewLatest,
customFieldLabelToKey, customFieldLabelToKey,
} from 'ontime-utils'; } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js'; import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js'; import { apply } from './delayUtils.js';
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js'; import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string; let currentRundownId: EntryId = '';
type NormalisedRundown = Record<EventID, OntimeRundownEntry>; let currentRundown: Rundown = {
id: '',
let persistedRundown: OntimeRundown = []; title: '',
order: [],
entries: {},
revision: 0,
};
let persistedCustomFields: CustomFields = {}; let persistedCustomFields: CustomFields = {};
/** /**
* Get the cached rundown without triggering regeneration * Get the cached rundown without triggering regeneration
*/ */
export const getPersistedRundown = (): OntimeRundown => persistedRundown; export const getCurrentRundown = (): Rundown => currentRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields; export const getCustomFields = (): CustomFields => persistedCustomFields;
let normalisedRundown: NormalisedRundown = {}; let playableEventsOrder: EntryId[] = [];
let order: EventID[] = []; let timedEventsOrder: EntryId[] = [];
let revision = 0; let flatIndexOrder: EntryId[] = [];
/** /**
* all mutating functions will set this value if there is a need for re-generation * 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 firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null; let lastEnd: MaybeNumber = null;
let links: Record<EventID, EventID> = {}; let links: Record<EntryId, EntryId> = {};
/** /**
* Object that contains reference of renamed custom fields * Object that contains reference of renamed custom fields
@@ -76,13 +85,17 @@ export const customFieldChangelog = new Map<string, string>();
* Keep track of which custom fields are used. * Keep track of which custom fields are used.
* This will be handy for when we delete custom fields * This will be handy for when we delete custom fields
*/ */
let assignedCustomFields: Record<CustomFieldLabel, EventID[]> = {}; let assignedCustomFields: Record<CustomFieldLabel, EntryId[]> = {};
export async function init(initialRundown: Readonly<OntimeRundown>, customFields: Readonly<CustomFields>) { /**
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<CustomFields>) {
currentRundown = structuredClone(initialRundown);
currentRundownId = initialRundown.id;
persistedCustomFields = structuredClone(customFields); persistedCustomFields = structuredClone(customFields);
generate(); generate();
await getDataProvider().setRundown(persistedRundown); await getDataProvider().setRundown(currentRundownId, currentRundown);
await getDataProvider().setCustomFields(customFields); await getDataProvider().setCustomFields(customFields);
} }
@@ -90,10 +103,7 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
* Utility generate cache * Utility generate cache
* @private should not be called outside of `rundownCache.ts` * @private should not be called outside of `rundownCache.ts`
*/ */
export function generate( export function generate(initialRundown: Rundown = currentRundown, customFields: CustomFields = persistedCustomFields) {
initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields,
) {
function clearIsStale() { function clearIsStale() {
isStale = false; isStale = false;
} }
@@ -102,8 +112,10 @@ export function generate(
// instead of maintaining logic to update it // instead of maintaining logic to update it
assignedCustomFields = {}; assignedCustomFields = {};
normalisedRundown = {}; playableEventsOrder = [];
order = []; timedEventsOrder = [];
flatIndexOrder = [];
links = {}; links = {};
firstStart = null; firstStart = null;
lastEnd = null; lastEnd = null;
@@ -111,20 +123,30 @@ export function generate(
totalDays = 0; totalDays = 0;
totalDelay = 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; 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 // 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)) { if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0; currentEntry.delay = 0;
currentEntry.gap = 0; currentEntry.gap = 0;
timedEventsOrder.push(currentEntryId);
// 1. handle links - mutates updatedEvent // 1. handle links - mutates currentEntry and links
handleLink(i, initialRundown, currentEntry, links); handleLink(currentEntry, previousEntry, links);
// 2. handle custom fields - mutates updatedEvent // 2. handle custom fields - mutates currentEntry
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields); handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
totalDays += calculateDayOffset(currentEntry, lastEntry); totalDays += calculateDayOffset(currentEntry, lastEntry);
@@ -132,6 +154,7 @@ export function generate(
// update rundown metadata, it only concerns playable events // update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) { if (isPlayableEvent(currentEntry)) {
playableEventsOrder.push(currentEntryId);
// fist start is always the first event // fist start is always the first event
if (firstStart === null) { if (firstStart === null) {
firstStart = currentEntry.timeStart; firstStart = currentEntry.timeStart;
@@ -160,6 +183,7 @@ export function generate(
// current event delay is the current accumulated delay // current event delay is the current accumulated delay
currentEntry.delay = totalDelay; currentEntry.delay = totalDelay;
previousEntry = currentEntry;
// lastEntry is the event with the latest end time // lastEntry is the event with the latest end time
if (isNewLatest(currentEntry, lastEntry)) { if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry; lastEntry = currentEntry;
@@ -178,17 +202,21 @@ export function generate(
} }
// add id to order // add id to order
order.push(currentEntry.id); parsedOrder.push(currentEntry.id);
// add entry to rundown // add entry to rundown
normalisedRundown[currentEntry.id] = currentEntry; parsedEntries[currentEntry.id] = currentEntry;
} }
lastEnd = lastEntry?.timeEnd ?? null; lastEnd = lastEntry?.timeEnd ?? null;
clearIsStale(); clearIsStale();
customFieldChangelog.clear(); customFieldChangelog.clear();
//The return value is used for testing // update the cache values
return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields }; 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 */ /** Returns an ID guaranteed to be unique */
@@ -199,21 +227,31 @@ export function getUniqueId(): string {
let id = ''; let id = '';
do { do {
id = generateId(); id = generateId();
} while (Object.hasOwn(normalisedRundown, id)); } while (Object.hasOwn(currentRundown.entries, id));
return id; return id;
} }
/** Returns index of an event with a given id */ /** Returns index of an event with a given id */
export function getIndexOf(eventId: string) { export function getIndexOf(eventId: EntryId) {
if (isStale) { if (isStale) {
generate(); 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 = { type RundownCache = {
rundown: NormalisedRundown; id: string;
order: string[]; title: string;
order: EntryId[];
entries: RundownEntries;
revision: number; revision: number;
totalDelay: number; totalDelay: number;
totalDuration: number; totalDuration: number;
@@ -228,19 +266,29 @@ export function get(): Readonly<RundownCache> {
generate(); generate();
} }
return { return {
rundown: normalisedRundown, id: currentRundown.id,
order, title: currentRundown.title,
revision, entries: currentRundown.entries,
order: currentRundown.order,
revision: currentRundown.revision,
totalDelay, totalDelay,
totalDuration, totalDuration,
}; };
} }
export type RundownMetadata = {
firstStart: MaybeNumber;
lastEnd: MaybeNumber;
totalDelay: number;
totalDuration: number;
revision: number;
};
/** /**
* Returns calculated metadata from rundown * Returns calculated metadata from rundown
* Will triggering regeneration if data is stale. * Will triggering regeneration if data is stale.
*/ */
export function getMetadata() { export function getMetadata(): Readonly<RundownMetadata> {
if (isStale) { if (isStale) {
generate(); generate();
} }
@@ -250,15 +298,35 @@ export function getMetadata() {
lastEnd, lastEnd,
totalDelay, totalDelay,
totalDuration, 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<RundownOrder> {
if (isStale) {
generate();
}
return {
order: currentRundown.order,
timedEventsOrder,
playableEventsOrder,
};
}
type CommonParams = { rundown: Rundown };
type MutationParams<T> = T & CommonParams; type MutationParams<T> = T & CommonParams;
type MutatingReturn = { type MutatingReturn = {
newRundown: OntimeRundown; newRundown: Rundown;
newEvent?: OntimeRundownEntry; newEvent?: OntimeEntry;
didMutate: boolean; didMutate: boolean;
}; };
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn; type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
@@ -269,15 +337,17 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
*/ */
export function mutateCache<T extends object>(mutation: MutatingFn<T>) { export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
function scopedMutation(params: T) { 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 // early return without calling side effects
if (!didMutate) { if (!didMutate) {
return { newEvent, newRundown, didMutate }; return { newEvent, newRundown, didMutate };
} }
revision = revision + 1; newRundown.revision += 1;
persistedRundown = newRundown; currentRundown = newRundown;
// schedule a non priority cache update // schedule a non priority cache update
setImmediate(() => { setImmediate(() => {
@@ -286,7 +356,7 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
// defer writing to the database // defer writing to the database
setImmediate(async () => { setImmediate(async () => {
await getDataProvider().setRundown(persistedRundown); await getDataProvider().setRundown(currentRundownId, currentRundown);
}); });
return { newEvent, newRundown, didMutate }; return { newEvent, newRundown, didMutate };
@@ -295,70 +365,91 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
return scopedMutation; return scopedMutation;
} }
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>; type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>;
/** /**
* Add entry to rundown * Add entry to rundown
*/ */
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> { export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event }; const newEvent: OntimeEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, rundown);
rundown.entries[newEvent.id] = newEvent;
rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order);
setIsStale(); 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 * Remove entry to rundown
*/ */
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = rundown.filter((event) => !eventIds.includes(event.id)); const previousLength = rundown.order.length;
const didMutate = rundown.length !== newRundown.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(); if (didMutate) setIsStale();
return { newRundown, didMutate }; return { newRundown: rundown, didMutate };
} }
export function removeAll(): MutatingReturn { export function removeAll(): MutatingReturn {
setIsStale(); 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 * Utility function for patching an existing event with new data
*/ */
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry { function makeEvent<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
if (isOntimeEvent(eventFromRundown)) { if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as OntimeEvent); const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
newEvent.revision++; newEvent.revision++;
return newEvent; return newEvent as T;
} }
// TODO: exhaustive check if (isOntimeBlock(eventFromRundown)) {
return { ...eventFromRundown, ...patch } as OntimeRundownEntry; const newEvent: OntimeBlock = { ...eventFromRundown, ...patch };
newEvent.revision++;
return newEvent as T;
}
return { ...eventFromRundown, ...patch } as T;
} }
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>; type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial<OntimeEntry> }>;
/** /**
* Apply patch to an entry with given id * Apply patch to an entry with given id
*/ */
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> { export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = rundown.findIndex((event) => event.id === eventId); const entry = rundown.entries[eventId];
if (indexAt < 0) { if (!entry) {
throw new Error('Event not found'); // 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'); throw new Error('Invalid event type');
} }
const eventInMemory = rundown[indexAt]; // if nothing changed, nothing to do
if (!hasChanges(entry, patch)) {
if (!hasChanges(eventInMemory, patch)) { return { newRundown: rundown, newEvent: entry, didMutate: false };
return { newRundown: rundown, newEvent: eventInMemory, didMutate: false };
} }
const newEvent = makeEvent(eventInMemory, patch); const newEvent = makeEvent(entry, patch);
rundown.entries[newEvent.id] = newEvent;
const newRundown = [...rundown];
newRundown[indexAt] = newEvent;
// check whether the data warrants recalculation of cache // check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch); const makeStale = isDataStale(patch);
@@ -366,91 +457,77 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingRe
if (makeStale) { if (makeStale) {
setIsStale(); setIsStale();
} else { } else {
normalisedRundown[newEvent.id] = newEvent; rundown.entries[newEvent.id] = newEvent;
} }
return { newRundown, newEvent, didMutate: true }; return { newRundown: rundown, newEvent, didMutate: true };
} }
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>; type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial<OntimeEntry> }>;
/** /**
* Apply patch to multiple entries * Apply patch to multiple entries
*/ */
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn { export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
const ids = new Set(eventIds); for (const eventId of eventIds) {
edit({ rundown, eventId, patch });
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]);
}
} }
setIsStale(); return { newRundown: rundown, didMutate: true };
return { newRundown, 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<MutatingReturn> { export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = rundown[from]; const eventFrom = rundown.entries[eventId];
if (!event || eventId !== event.id) { if (!eventFrom) {
throw new Error('Event not found'); 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++) { for (let i = from; i <= to; i++) {
const event = newRundown.at(i); const eventId = rundown.order[i];
if (isOntimeEvent(event)) { const entry = rundown.entries[eventId];
event.revision += 1; if (isOntimeEvent(entry) || isOntimeBlock(entry)) {
entry.revision += 1;
} }
} }
setIsStale(); 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 * Apply a delay
*/ */
export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn { export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, rundown); apply(delayId, rundown);
setIsStale(); 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 * Swap two entries
*/ */
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn { export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = rundown.findIndex((event) => event.id === fromId); const fromEvent = rundown.entries[fromId];
const eventA = rundown.at(indexA); const toEvent = rundown.entries[toId];
const indexB = rundown.findIndex((event) => event.id === toId); if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) {
const eventB = rundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
throw new Error('Swap only available for OntimeEvents'); throw new Error('Swap only available for OntimeEvents');
} }
const { newA, newB } = swapEventData(eventA, eventB); const [newFrom, newTo] = swapEventData(fromEvent, toEvent);
const newRundown = [...rundown];
newRundown[indexA] = newA; rundown.entries[fromId] = newFrom;
(newRundown[indexA] as OntimeEvent).revision += 1; rundown.entries[toId] = newTo;
newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1;
setIsStale(); setIsStale();
return { newRundown, didMutate: true }; return { newRundown: rundown, didMutate: true };
} }
/** /**
@@ -468,7 +545,7 @@ function invalidateIfUsed(label: CustomFieldLabel) {
// schedule a non priority cache update // schedule a non priority cache update
setImmediate(async () => { setImmediate(async () => {
generate(); generate();
await getDataProvider().setRundown(persistedRundown); await getDataProvider().setRundown(currentRundownId, currentRundown);
}); });
} }
@@ -1,57 +1,35 @@
import { import { OntimeEvent, CustomFieldLabel, CustomFields, OntimeEntry, OntimeBaseEvent } from 'ontime-types';
OntimeEvent,
isOntimeEvent,
OntimeRundown,
CustomFieldLabel,
CustomFields,
OntimeRundownEntry,
OntimeBaseEvent,
} from 'ontime-types';
import { dayInMs, getLinkedTimes } from 'ontime-utils'; import { dayInMs, getLinkedTimes } from 'ontime-utils';
/** /**
* Get linked event * Checks that link can be established (ie, events exist and are valid)
*/ * and populates the time data from link
export function getLink(currentIndex: number, rundown: OntimeRundown): OntimeEvent | null { * With the current implementation, the links is always the previous playable event
// currently the link is the previous event * Mutates mutableEvent in place
for (let i = currentIndex - 1; i >= 0; i--) { * Mutates links in place
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
*/ */
export function handleLink( export function handleLink(
currentIndex: number,
rundown: OntimeRundown,
mutableEvent: OntimeEvent, mutableEvent: OntimeEvent,
previousEvent: OntimeEvent | null,
links: Record<string, string>, links: Record<string, string>,
): void { ): void {
if (!mutableEvent.linkStart) { if (!mutableEvent.linkStart) {
return; return;
} }
const linkedEvent = getLink(currentIndex, rundown); /**
if (!linkedEvent) { * If no previous event exist, we dont remove the link
mutableEvent.linkStart = null; * 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; return;
} }
// sometimes the client cannot set the previous event const timePatch = getLinkedTimes(mutableEvent, previousEvent);
if (mutableEvent.linkStart === 'true') { mutableEvent.linkStart = previousEvent.id;
mutableEvent.linkStart = linkedEvent.id; links[previousEvent.id] = mutableEvent.id;
}
links[linkedEvent.id] = mutableEvent.id;
const timePatch = getLinkedTimes(mutableEvent, linkedEvent);
// use object.assign to force mutation // use object.assign to force mutation
Object.assign(mutableEvent, timePatch); Object.assign(mutableEvent, timePatch);
} }
@@ -124,7 +102,7 @@ enum RegenerateWhitelist {
* given a patch, returns whether all keys are whitelisted * given a patch, returns whether all keys are whitelisted
* @param path * @param path
*/ */
export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean { export function isDataStale(patch: Partial<OntimeEntry>): boolean {
return Object.keys(patch).some((key) => !(key in RegenerateWhitelist)); return Object.keys(patch).some((key) => !(key in RegenerateWhitelist));
} }
@@ -156,7 +134,7 @@ export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent
*/ */
export function calculateDayOffset( export function calculateDayOffset(
current: Pick<OntimeEvent, 'timeStart'>, current: Pick<OntimeEvent, 'timeStart'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration'>, previous: Pick<OntimeEvent, 'timeStart' | 'duration'> | null,
) { ) {
// if there is no previous there can't be a day offset // if there is no previous there can't be a day offset
if (!previous) { if (!previous) {
@@ -1,61 +1,90 @@
import { OntimeEvent, OntimeRundown, RundownCached, OntimeRundownEntry, PlayableEvent } from 'ontime-types'; import {
import { filterPlayable, filterTimedEvents } from 'ontime-utils'; OntimeEvent,
Rundown,
OntimeEntry,
PlayableEvent,
EntryId,
RundownEntries,
ProjectRundowns,
} from 'ontime-types';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
/** /**
* returns the normalised rundown * returns entire unfiltered rundown
*/ */
export function getNormalisedRundown(): RundownCached { export function getCurrentRundown(): Rundown {
return cache.get(); return cache.getCurrentRundown();
} }
/** /**
* returns entire unfiltered rundown * returns the the project rundown and the order arrays
*/ */
export function getRundown(): OntimeRundown { export function getRundownData() {
return cache.getPersistedRundown(); return {
rundown: cache.getCurrentRundown(),
rundownOrder: cache.getEventOrder(),
};
} }
/** /**
* returns all events of type OntimeEvent * returns all events of type OntimeEvent
*/ */
export function getTimedEvents(): 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[] { function makeFlatRundownFromOrder<T>(order: EntryId[], events: RundownEntries): T[] {
return filterPlayable(getRundown()); return order.map((id) => events[id] as T);
} }
/** /**
* returns an event given its index after filtering for OntimeEvents * returns an event given its index after filtering for OntimeEvents
*/ */
export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined { export function getEventAtIndex(eventIndex: number): OntimeEvent | undefined {
const timedEvents = getTimedEvents(); const { timedEventsOrder } = cache.getEventOrder();
return timedEvents.at(eventIndex); 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 * returns first event that matches a given ID
*/ */
export function getEventWithId(eventId: string): OntimeRundownEntry | undefined { export function getEventWithId(eventId: string): OntimeEntry | undefined {
const rundown = getRundown(); const { entries } = getCurrentRundown();
return rundown.find((event) => event.id === eventId); 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 * returns first event that matches a given cue
*/ */
export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined { export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): OntimeEvent | undefined {
const playableEvents = getPlayableEvents(); const { playableEventsOrder } = cache.getEventOrder();
const lowerCaseCue = targetCue.toLowerCase(); const lowerCaseCue = targetCue.toLowerCase();
for (let i = currentEventIndex; i < playableEvents.length; i++) { for (let i = currentEventIndex; i < playableEventsOrder.length; i++) {
const event = playableEvents.at(i); const eventId = playableEventsOrder[i];
const event = getEventWithId(eventId) as PlayableEvent | undefined;
if (event?.cue.toLowerCase() === lowerCaseCue) { if (event?.cue.toLowerCase() === lowerCaseCue) {
return event; return event;
} }
@@ -65,39 +94,74 @@ export function getNextEventWithCue(targetCue: string, currentEventIndex = 0): O
/** /**
* finds the previous event * finds the previous event
*/ */
export function findPrevious(currentEventId?: string): OntimeEvent | null { export function findPrevious(currentEventId?: string): OntimeEvent | undefined {
const playableEvents = getPlayableEvents(); const { playableEventsOrder } = cache.getEventOrder();
if (!playableEvents || !playableEvents.length) {
return null; if (!playableEventsOrder.length) {
return;
} }
// if there is no event running, go to first // if there is no event running, go to first
if (!currentEventId) { 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 newIndex = Math.max(currentIndex - 1, 0);
const previousEvent = playableEvents.at(newIndex) ?? null; const previousEventId = playableEventsOrder.at(newIndex);
return previousEvent;
if (!previousEventId) {
return getFirstPlayable(playableEventsOrder);
}
return getEventWithId(previousEventId) as PlayableEvent | undefined;
} }
/** /**
* finds the next event * finds the next event
*/ */
export function findNext(currentEventId?: string): PlayableEvent | null { export function findNext(currentEventId?: string): PlayableEvent | undefined {
const playableEvents = getPlayableEvents(); const { playableEventsOrder } = cache.getEventOrder();
if (!playableEvents.length) {
return null; if (!playableEventsOrder.length) {
return;
} }
// if there is no event running, go to first // if there is no event running, go to first
if (!currentEventId) { 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 = currentIndex + 1; const newIndex = Math.min(currentIndex + 1, playableEventsOrder.length - 1);
const nextEvent = playableEvents.at(newIndex); const nextEventId = playableEventsOrder.at(newIndex);
return nextEvent ?? null;
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];
} }
@@ -30,13 +30,15 @@ import {
getEventAtIndex, getEventAtIndex,
getNextEventWithCue, getNextEventWithCue,
getEventWithId, getEventWithId,
getRundown, getCurrentRundown,
getTimedEvents, getTimedEvents,
getRundownData,
} from '../rundown-service/rundownUtils.js'; } from '../rundown-service/rundownUtils.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js'; import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js'; import { skippedOutOfEvent } from '../timerUtils.js';
import { triggerAutomations } from '../../api-data/automation/automation.service.js'; import { triggerAutomations } from '../../api-data/automation/automation.service.js';
import { getEventOrder } from '../rundown-service/rundownCache.js';
type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>; type RuntimeStateEventKeys = keyof Pick<RuntimeState, 'eventNext' | 'eventNow' | 'publicEventNow' | 'publicEventNext'>;
@@ -264,8 +266,9 @@ class RuntimeService {
if (onlyChangedNow) { if (onlyChangedNow) {
runtimeState.updateLoaded(eventNow); runtimeState.updateLoaded(eventNow);
} else { } else {
const rundown = getRundown(); const rundown = getCurrentRundown();
runtimeState.updateAll(rundown); const { timedEventsOrder } = getEventOrder();
runtimeState.updateAll(rundown, timedEventsOrder);
} }
return; return;
} }
@@ -292,8 +295,8 @@ class RuntimeService {
} }
const previousState = runtimeState.getState(); const previousState = runtimeState.getState();
const rundown = getRundown(); const { rundown, rundownOrder } = getRundownData();
const success = runtimeState.load(event, rundown, initialData); const success = runtimeState.load(event, rundown, rundownOrder.timedEventsOrder, initialData);
if (success) { if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
@@ -577,9 +580,11 @@ class RuntimeService {
* Handles special case to call roll on a loaded event which we do not want to discard * Handles special case to call roll on a loaded event which we do not want to discard
*/ */
private rollLoaded(offset?: number) { private rollLoaded(offset?: number) {
const rundown = getRundown(); const rundown = getCurrentRundown();
const { timedEventsOrder } = getEventOrder();
try { try {
runtimeState.roll(rundown, offset); runtimeState.roll(rundown, timedEventsOrder, offset);
} catch (error) { } catch (error) {
logger.error(LogOrigin.Server, `Roll: ${error}`); logger.error(LogOrigin.Server, `Roll: ${error}`);
} }
@@ -599,8 +604,8 @@ class RuntimeService {
} }
try { try {
const rundown = getRundown(); const { rundown, rundownOrder } = getRundownData();
const result = runtimeState.roll(rundown); const result = runtimeState.roll(rundown, rundownOrder.timedEventsOrder);
const newState = runtimeState.getState(); const newState = runtimeState.getState();
if (result.eventId !== previousState.eventNow?.id) { if (result.eventId !== previousState.eventNow?.id) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`);
@@ -651,8 +656,8 @@ class RuntimeService {
return; return;
} }
const rundown = getRundown(); const { rundown, rundownOrder } = getRundownData();
runtimeState.resume(restorePoint, event, rundown); runtimeState.resume(restorePoint, event, rundown, rundownOrder.timedEventsOrder);
logger.info(LogOrigin.Playback, 'Resuming playback'); logger.info(LogOrigin.Playback, 'Resuming playback');
} }
@@ -4,7 +4,7 @@
* @link https://developers.google.com/identity/protocols/oauth2/limited-input-device * @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 { ImportMap, getErrorMessage } from 'ontime-utils';
import { sheets, type sheets_v4 } from '@googleapis/sheets'; import { sheets, type sheets_v4 } from '@googleapis/sheets';
@@ -13,8 +13,8 @@ import got from 'got';
import { parseExcel } from '../../utils/parser.js'; import { parseExcel } from '../../utils/parser.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { parseRundown } from '../../utils/parserFunctions.js'; import { parseRundowns } from '../../utils/parserFunctions.js';
import { getRundown } from '../rundown-service/rundownUtils.js'; import { getCurrentRundown, getRundownOrThrow } from '../rundown-service/rundownUtils.js';
import { getCustomFields } from '../rundown-service/rundownCache.js'; import { getCustomFields } from '../rundown-service/rundownCache.js';
import { cellRequestFromEvent, type ClientSecret, getA1Notation, validateClientSecret } from './sheetUtils.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}`); throw new Error(`Sheet read failed: ${readResponse.statusText}`);
} }
const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), options); const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options);
const rundown = getRundown(); const rundown = getCurrentRundown();
const titleRow = Object.values(rundownMetadata)[0]['row']; const titleRow = Object.values(rundownMetadata)[0]['row'];
const updateRundown = Array<sheets_v4.Schema$Request>(); const updateRundown = Array<sheets_v4.Schema$Request>();
@@ -322,16 +322,17 @@ export async function upload(sheetId: string, options: ImportMap) {
range: { range: {
dimension: 'ROWS', dimension: 'ROWS',
startIndex: titleRow + 1, startIndex: titleRow + 1,
endIndex: titleRow + rundown.length, endIndex: titleRow + rundown.order.length,
sheetId: worksheetId, sheetId: worksheetId,
}, },
}, },
}); });
// update the corresponding row with event data // update the corresponding row with event data
rundown.forEach((entry, index) => rundown.order.forEach((entryId, index) => {
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)), const entry = rundown.entries[entryId];
); return updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata));
});
const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({ const writeResponse = await sheets({ version: 'v4', auth: currentAuthClient }).spreadsheets.batchUpdate({
spreadsheetId: sheetId, spreadsheetId: sheetId,
@@ -353,7 +354,7 @@ export async function download(
sheetId: string, sheetId: string,
options: ImportMap, options: ImportMap,
): Promise<{ ): Promise<{
rundown: OntimeRundown; rundown: Rundown;
customFields: CustomFields; customFields: CustomFields;
}> { }> {
const { range } = await verifyWorksheet(sheetId, options.worksheet); const { range } = await verifyWorksheet(sheetId, options.worksheet);
@@ -369,10 +370,19 @@ export async function download(
throw new Error(`Sheet read failed: ${googleResponse.statusText}`); throw new Error(`Sheet read failed: ${googleResponse.statusText}`);
} }
const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), options); const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options);
const { customFields, rundown } = parseRundown(dataFromSheet);
if (rundown.length < 1) { const rundownId = dataFromSheet.rundown.id;
const dataModel: Pick<DatabaseModel, 'rundowns' | 'customFields'> = {
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'); throw new Error('Sheet: Could not find data to import in the worksheet');
} }
return { rundown, customFields }; return { rundown: rundowns[rundownId], customFields };
} }
@@ -39,6 +39,7 @@ describe('cellRequestFromEvent()', () => {
delay: 0, delay: 0,
gap: 0, gap: 0,
dayOffset: 0, dayOffset: 0,
currentBlock: null,
revision: 0, revision: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
@@ -84,6 +85,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: false, isPublic: false,
skip: false, skip: false,
colour: 'red', colour: 'red',
currentBlock: null,
revision: 0, revision: 0,
delay: 0, delay: 0,
gap: 0, gap: 0,
@@ -134,6 +136,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true, isPublic: true,
skip: false, skip: false,
colour: 'red', colour: 'red',
currentBlock: null,
revision: 0, revision: 0,
delay: 0, delay: 0,
gap: 0, gap: 0,
@@ -186,6 +189,7 @@ describe('cellRequestFromEvent()', () => {
delay: 0, delay: 0,
gap: 0, gap: 0,
dayOffset: 0, dayOffset: 0,
currentBlock: null,
revision: 0, revision: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
@@ -218,6 +222,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true, isPublic: true,
skip: false, skip: false,
colour: 'red', colour: 'red',
currentBlock: null,
revision: 0, revision: 0,
delay: 0, delay: 0,
gap: 0, gap: 0,
@@ -254,6 +259,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true, isPublic: true,
skip: false, skip: false,
colour: 'red', colour: 'red',
currentBlock: null,
revision: 0, revision: 0,
delay: 0, delay: 0,
gap: 0, gap: 0,
@@ -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 { millisToString } from 'ontime-utils';
import type { sheets_v4 } from '@googleapis/sheets'; 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 * @description - creates updateCells request from ontime event
* @param {OntimeRundownEntry} event * @param {OntimeEntry} event
* @param {number} index - index of the event * @param {number} index - index of the event
* @param {number} worksheetId * @param {number} worksheetId
* @param {object} metadata - object with all the cell positions of the title of each attribute * @param {object} metadata - object with all the cell positions of the title of each attribute
* @returns {sheets_v4.Schema} - list of update requests * @returns {sheets_v4.Schema} - list of update requests
*/ */
export function cellRequestFromEvent( export function cellRequestFromEvent(
event: OntimeRundownEntry, event: OntimeEntry,
index: number, index: number,
worksheetId: number, worksheetId: number,
metadata: object, 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 (isOntimeEvent(event)) {
if (key === 'blank') { if (key === 'blank') {
return {}; return {};
@@ -1,5 +1,11 @@
import { OntimeRundown, PlayableEvent, Playback, SupportedEvent, TimerPhase } from 'ontime-types'; import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { initRundown } from '../../services/rundown-service/RundownService.js';
import {
makeOntimeBlock,
makeOntimeEvent,
makeRundown,
} from '../../services/rundown-service/__mocks__/rundown.mocks.js';
import { import {
type RuntimeState, type RuntimeState,
@@ -13,7 +19,6 @@ import {
start, start,
stop, stop,
} from '../runtimeState.js'; } from '../runtimeState.js';
import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = { const mockEvent = {
type: 'event', type: 'event',
@@ -23,6 +28,7 @@ const mockEvent = {
timeEnd: 1000, timeEnd: 1000,
duration: 1000, duration: 1000,
skip: false, skip: false,
currentBlock: null,
} as PlayableEvent; } as PlayableEvent;
const mockState = { const mockState = {
@@ -51,11 +57,6 @@ const mockState = {
}, },
} as RuntimeState; } as RuntimeState;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const makeMockState = (patch: RuntimeState): RuntimeState => {
return deepmerge(mockState, patch);
};
beforeAll(() => { beforeAll(() => {
vi.mock('../../classes/data-provider/DataProvider.js', () => { vi.mock('../../classes/data-provider/DataProvider.js', () => {
return { return {
@@ -70,23 +71,14 @@ beforeAll(() => {
}); });
describe('mutation on runtimeState', () => { describe('mutation on runtimeState', () => {
beforeEach(() => { beforeEach(async () => {
clear(); clear();
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => { vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
const actual = (await importOriginal()) as object; const actual = (await importOriginal()) as object;
return { return {
...actual, ...actual,
getPlayableEvents: vi.fn().mockReturnValue([ initRunddown: vi.fn().mockReturnValue(undefined),
{
id: 'mock',
cue: 'mock',
timeStart: 0,
timeEnd: 1000,
duration: 1000,
},
]),
}; };
}); });
}); });
@@ -97,15 +89,18 @@ describe('mutation on runtimeState', () => {
describe('playback operations', async () => { describe('playback operations', async () => {
it('refuses if nothing is loaded', () => { it('refuses if nothing is loaded', () => {
initRundown(makeRundown({}), {});
let success = start(mockState); let success = start(mockState);
expect(success).toBe(false); expect(success).toBe(false);
success = pause(); success = pause();
expect(success).toBe(false); expect(success).toBe(false);
}); });
test('normal playback cycle', () => { test('normal playback cycle', () => {
// 1. Load event // 1. Load event
load(mockEvent, [mockEvent]); const mockRundown = makeRundown({ entries: { [mockEvent.id]: mockEvent }, order: [mockEvent.id] });
load(mockEvent, mockRundown, mockRundown.order);
let newState = getState(); let newState = getState();
expect(newState.eventNow?.id).toBe(mockEvent.id); expect(newState.eventNow?.id).toBe(mockEvent.id);
expect(newState.timer.playback).toBe(Playback.Armed); 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 // do this before the test so that it is applied
const event1 = { ...mockEvent, id: 'event1', timeStart: 0, timeEnd: 1000, duration: 1000 }; const entries = {
const event2 = { ...mockEvent, id: 'event2', timeStart: 1000, timeEnd: 1500, duration: 500 }; 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 // force update
vi.useFakeTimers(); vi.useFakeTimers();
await initRundown([event1, event2], {}); await initRundown(rundown, {});
vi.runAllTimers(); vi.runAllTimers();
vi.useRealTimers(); vi.useRealTimers();
test('runtime offset', async () => { test('runtime offset', async () => {
// 1. Load event // 1. Load event
load(event1, [event1, event2]); load(entries.event1, rundown, rundown.order);
let newState = getState(); let newState = getState();
expect(newState.runtime.actualStart).toBeNull(); expect(newState.runtime.actualStart).toBeNull();
expect(newState.runtime.plannedStart).toBe(0); expect(newState.runtime.plannedStart).toBe(0);
@@ -197,11 +196,11 @@ describe('mutation on runtimeState', () => {
} }
expect(newState.runtime.actualStart).toBe(newState.clock); expect(newState.runtime.actualStart).toBe(newState.clock);
expect(newState.runtime.offset).toBe(event1.timeStart - newState.clock); expect(newState.runtime.offset).toBe(entries.event1.timeStart - newState.clock);
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset); expect(newState.runtime.expectedEnd).toBe(entries.event2.timeEnd - newState.runtime.offset);
// 3. Next event // 3. Next event
load(event2, [event1, event2]); load(entries.event2, rundown, rundown.order);
start(); start();
newState = getState(); newState = getState();
@@ -214,10 +213,10 @@ describe('mutation on runtimeState', () => {
const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart); const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart);
expect(forgivingActualStart).toBeLessThanOrEqual(1); expect(forgivingActualStart).toBeLessThanOrEqual(1);
// we are over-under, the difference between the schedule and the actual start // 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); expect(newState.runtime.offset).toBe(delayBefore);
// finish is the difference between the runtime and the schedule // 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(); expect(newState.currentBlock.block).toBeNull();
// 4. Add time // 4. Add time
@@ -228,7 +227,7 @@ describe('mutation on runtimeState', () => {
} }
expect(newState.runtime.offset).toBe(delayBefore - 10); 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 // 5. Stop event
stop(); stop();
@@ -237,8 +236,6 @@ describe('mutation on runtimeState', () => {
expect(newState.runtime.offset).toBe(0); expect(newState.runtime.offset).toBe(0);
expect(newState.runtime.expectedEnd).toBeNull(); expect(newState.runtime.expectedEnd).toBeNull();
}); });
test.todo('runtime offset on timers in overtime', () => {});
}); });
}); });
@@ -253,14 +250,17 @@ describe('roll mode', () => {
}); });
describe('normal roll', () => { describe('normal roll', () => {
const rundown = [ const rundown = makeRundown({
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, entries: {
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
] as PlayableEvent[]; 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
},
order: ['1', '2', '3'],
});
test('pending event', () => { test('pending event', () => {
const { eventId, didStart } = roll(rundown); const { eventId, didStart } = roll(rundown, rundown.order);
const state = getState(); const state = getState();
expect(eventId).toBe('1'); expect(eventId).toBe('1');
@@ -271,29 +271,32 @@ describe('roll mode', () => {
test('roll events', () => { test('roll events', () => {
vi.setSystemTime('jan 1 00:00:01'); vi.setSystemTime('jan 1 00:00:01');
let result = roll(rundown); let result = roll(rundown, rundown.order);
expect(result).toStrictEqual({ eventId: '1', didStart: true }); expect(result).toStrictEqual({ eventId: '1', didStart: true });
vi.setSystemTime('jan 1 00:00:02'); vi.setSystemTime('jan 1 00:00:02');
result = roll(rundown); result = roll(rundown, rundown.order);
expect(result).toStrictEqual({ eventId: '2', didStart: true }); expect(result).toStrictEqual({ eventId: '2', didStart: true });
vi.setSystemTime('jan 1 00:00:03:500'); vi.setSystemTime('jan 1 00:00:03:500');
result = roll(rundown); result = roll(rundown, rundown.order);
expect(result).toStrictEqual({ eventId: '3', didStart: true }); expect(result).toStrictEqual({ eventId: '3', didStart: true });
}); });
}); });
describe('roll takover', () => { describe('roll takover', () => {
const rundown = [ const rundown = makeRundown({
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, entries: {
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
] as PlayableEvent[]; 3: { ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 },
},
order: ['1', '2', '3'],
});
test('from load', () => { test('from load', () => {
load(rundown[2], rundown); load(rundown.entries[3] as PlayableEvent, rundown, rundown.order);
const result = roll(rundown); const result = roll(rundown, rundown.order);
expect(result).toStrictEqual({ eventId: '3', didStart: false }); expect(result).toStrictEqual({ eventId: '3', didStart: false });
const state = getState(); const state = getState();
expect(state.timer.phase).toBe(TimerPhase.Pending); expect(state.timer.phase).toBe(TimerPhase.Pending);
@@ -301,9 +304,9 @@ describe('roll mode', () => {
}); });
test('from play', () => { test('from play', () => {
load(rundown[0], rundown); load(rundown.entries[1] as PlayableEvent, rundown, rundown.order);
start(); start();
const result = roll(rundown); const result = roll(rundown, rundown.order);
expect(result).toStrictEqual({ eventId: '1', didStart: false }); expect(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000); expect(getState().runtime.offset).toBe(1000);
}); });
@@ -311,153 +314,167 @@ describe('roll mode', () => {
describe('roll continue with offset', () => { describe('roll continue with offset', () => {
test('no gaps', () => { test('no gaps', () => {
const rundown = [ const rundown = makeRundown({
{ ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 }, entries: {
{ ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 }, 1: { ...mockEvent, id: '1', timeStart: 1000, duration: 1000, timeEnd: 2000 },
{ ...mockEvent, id: '3', timeStart: 3000, duration: 1000, timeEnd: 4000 }, 2: { ...mockEvent, id: '2', timeStart: 2000, duration: 1000, timeEnd: 3000 },
] as PlayableEvent[]; 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(); 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(result).toStrictEqual({ eventId: '1', didStart: false });
expect(getState().runtime.offset).toBe(1000); expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:01'); 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(result).toStrictEqual({ eventId: '2', didStart: true });
expect(getState().runtime.offset).toBe(1000); expect(getState().runtime.offset).toBe(1000);
vi.setSystemTime('jan 1 00:00:02'); 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(result).toStrictEqual({ eventId: '3', didStart: true });
expect(getState().runtime.offset).toBe(1000); 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', () => { describe('loadBlock', () => {
test('from no-block to a block will clear startedAt', () => { test('from no-block to a block will clear startedAt', () => {
const rundown = [ const rundown = makeRundown({
{ id: '0', type: SupportedEvent.Event }, entries: {
{ id: '1', type: SupportedEvent.Block }, 0: makeOntimeEvent({ id: '0', currentBlock: null }),
{ id: '2', type: SupportedEvent.Event }, 1: makeOntimeBlock({ id: '1', events: ['11'] }),
{ id: '3', type: SupportedEvent.Block }, 11: makeOntimeEvent({ id: '11', currentBlock: '1' }),
{ id: '4', type: SupportedEvent.Event }, 2: makeOntimeBlock({ id: '2', events: [] }),
] as OntimeRundown; 3: makeOntimeEvent({ id: '3', currentBlock: null }),
},
order: ['0', '1', '2', '3'],
});
const state = { const state = {
currentBlock: { currentBlock: {
block: null, block: null,
startedAt: 123, startedAt: 123,
}, },
eventNow: rundown[2], eventNow: rundown.entries[11],
} as RuntimeState; } as RuntimeState;
loadBlock(rundown, state); loadBlock(rundown, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
currentBlock: { block: rundown[1], startedAt: null }, currentBlock: { block: rundown.entries[1], startedAt: null },
eventNow: rundown[2], eventNow: rundown.entries[11],
}); });
}); });
test('from block to a different block will clear startedAt', () => { test('from block to a different block will clear startedAt', () => {
const rundown = [ const rundown = makeRundown({
{ id: '0', type: SupportedEvent.Event }, entries: {
{ id: '1', type: SupportedEvent.Block }, 0: makeOntimeEvent({ id: '0', currentBlock: null }),
{ id: '2', type: SupportedEvent.Event }, 1: makeOntimeBlock({ id: '1', events: ['11'] }),
{ id: '3', type: SupportedEvent.Block }, 11: makeOntimeEvent({ id: '11', currentBlock: '1' }),
{ id: '4', type: SupportedEvent.Event }, 2: makeOntimeBlock({ id: '2', events: ['22'] }),
] as OntimeRundown; 22: makeOntimeEvent({ id: '22', currentBlock: '2' }),
},
order: ['0', '1', '2'],
});
const state = { const state = {
currentBlock: { currentBlock: {
block: rundown[1], block: rundown.entries[1],
startedAt: 123, startedAt: 123,
}, },
eventNow: rundown[4], eventNow: rundown.entries[22],
} as RuntimeState; } as RuntimeState;
loadBlock(rundown, state); loadBlock(rundown, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
currentBlock: { block: rundown[3], startedAt: null }, currentBlock: { block: rundown.entries[2], startedAt: null },
eventNow: rundown[4], eventNow: rundown.entries[22],
}); });
}); });
test('from block to a no-block will clear startedAt', () => { test('from block to a no-block will clear startedAt', () => {
const rundown = [ const rundown = makeRundown({
{ id: '0', type: SupportedEvent.Event }, entries: {
{ id: '1', type: SupportedEvent.Block }, 0: makeOntimeEvent({ id: '0', currentBlock: null }),
{ id: '2', type: SupportedEvent.Event }, 1: makeOntimeBlock({ id: '1', events: ['11'] }),
{ id: '3', type: SupportedEvent.Block }, 11: makeOntimeEvent({ id: '11', currentBlock: '1' }),
{ id: '4', type: SupportedEvent.Event }, 2: makeOntimeBlock({ id: '2', events: ['22'] }),
] as OntimeRundown; 22: makeOntimeEvent({ id: '22', currentBlock: '2' }),
},
order: ['0', '1', '2'],
});
const state = { const state = {
currentBlock: { currentBlock: {
block: rundown[1], block: rundown.entries[1],
startedAt: 123, startedAt: 123,
}, },
eventNow: rundown[0], eventNow: rundown.entries[0],
} as RuntimeState; } as RuntimeState;
loadBlock(rundown, state); loadBlock(rundown, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
currentBlock: { block: null, startedAt: null }, currentBlock: { block: null, startedAt: null },
eventNow: rundown[0], eventNow: rundown.entries[0],
}); });
}); });
test('from block to same block will keep startedAt', () => { test('from block to same block will keep startedAt', () => {
const rundown = [ const rundown = makeRundown({
{ id: '0', type: SupportedEvent.Block }, entries: {
{ id: '1', type: SupportedEvent.Event }, 0: makeOntimeBlock({ id: '0', events: ['1', '2'] }),
{ id: '2', type: SupportedEvent.Event }, 1: makeOntimeEvent({ id: '1', currentBlock: '0' }),
] as OntimeRundown; 2: makeOntimeEvent({ id: '2', currentBlock: '0' }),
},
order: ['0'],
});
const state = { const state = {
currentBlock: { currentBlock: {
block: rundown[0], block: rundown.entries[0],
startedAt: 123, startedAt: 123,
}, },
eventNow: rundown[2], eventNow: rundown.entries[2],
} as RuntimeState; } as RuntimeState;
loadBlock(rundown, state); loadBlock(rundown, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
currentBlock: { block: rundown[0], startedAt: 123 }, currentBlock: { block: rundown.entries[0], startedAt: 123 },
eventNow: rundown[2], eventNow: rundown.entries[2],
}); });
}); });
test('from no-block to no-block will keep startedAt', () => { test('from no-block to no-block will keep startedAt', () => {
const rundown = [ const rundown = makeRundown({
{ id: '0', type: SupportedEvent.Event }, entries: {
{ id: '1', type: SupportedEvent.Event }, 0: makeOntimeEvent({ id: '0', currentBlock: null }),
] as OntimeRundown; 1: makeOntimeEvent({ id: '1', currentBlock: null }),
},
order: ['0', '1'],
});
const state = { const state = {
currentBlock: { currentBlock: {
block: null, block: null,
startedAt: 123, startedAt: 123,
}, },
eventNow: rundown[0], eventNow: rundown.entries[0],
} as RuntimeState; } as RuntimeState;
loadBlock(rundown, state); loadBlock(rundown, state);
expect(state).toMatchObject({ expect(state).toMatchObject({
currentBlock: { block: null, startedAt: 123 }, currentBlock: { block: null, startedAt: 123 },
eventNow: rundown[0], eventNow: rundown.entries[0],
}); });
}); });
}); });
+36 -26
View File
@@ -1,25 +1,20 @@
import { import {
CurrentBlockState, CurrentBlockState,
EntryId,
isPlayableEvent, isPlayableEvent,
MaybeNumber, MaybeNumber,
MaybeString, MaybeString,
OntimeBlock,
OntimeEvent, OntimeEvent,
OntimeRundown,
PlayableEvent, PlayableEvent,
Playback, Playback,
Rundown,
Runtime, Runtime,
runtimeStorePlaceholder, runtimeStorePlaceholder,
TimerPhase, TimerPhase,
TimerState, TimerState,
} from 'ontime-types'; } from 'ontime-types';
import { import { calculateDuration, checkIsNow, dayInMs, isPlaybackActive } from 'ontime-utils';
calculateDuration,
checkIsNow,
dayInMs,
filterTimedEvents,
getPreviousBlock,
isPlaybackActive,
} from 'ontime-utils';
import { timeNow } from '../utils/time.js'; import { timeNow } from '../utils/time.js';
import type { RestorePoint } from '../services/RestoreService.js'; import type { RestorePoint } from '../services/RestoreService.js';
@@ -32,6 +27,7 @@ import {
} from '../services/timerUtils.js'; } from '../services/timerUtils.js';
import { timerConfig } from '../config/config.js'; import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js'; import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
import { filterTimedEvents } from '../services/rundown-service/rundownUtils.js';
export type RuntimeState = { export type RuntimeState = {
clock: number; // realtime clock clock: number; // realtime clock
@@ -151,7 +147,8 @@ export function updateRundownData(rundownData: RundownData) {
*/ */
export function load( export function load(
event: PlayableEvent, event: PlayableEvent,
rundown: OntimeRundown, rundown: Rundown,
timedEventsOrder: EntryId[],
initialData?: Partial<TimerState & RestorePoint>, initialData?: Partial<TimerState & RestorePoint>,
): boolean { ): boolean {
// we need to persist the current block state across loads // we need to persist the current block state across loads
@@ -159,14 +156,17 @@ export function load(
clear(); clear();
runtimeState.currentBlock = prevCurrentBlock; runtimeState.currentBlock = prevCurrentBlock;
// filter rundown if (timedEventsOrder.length === 0 || !isPlayableEvent(event)) {
const timedEvents = filterTimedEvents(rundown);
const eventIndex = timedEvents.findIndex((eventInMemory) => eventInMemory.id === event.id);
if (timedEvents.length === 0 || eventIndex === -1 || !isPlayableEvent(event)) {
return false; 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 // load events in memory along with their data
loadNow(timedEvents, eventIndex); loadNow(timedEvents, eventIndex);
loadNext(timedEvents, eventIndex); loadNext(timedEvents, eventIndex);
@@ -281,8 +281,8 @@ export function loadNext(
/** /**
* Resume from restore point * Resume from restore point
*/ */
export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: OntimeRundown) { export function resume(restorePoint: RestorePoint, event: PlayableEvent, rundown: Rundown, timedEventOrder: EntryId[]) {
load(event, rundown, restorePoint); load(event, rundown, timedEventOrder, restorePoint);
} }
/** /**
@@ -342,8 +342,8 @@ export function updateLoaded(event?: PlayableEvent): string | undefined {
/** /**
* Used in situations when we want to hot-reload all events without interrupting timer * Used in situations when we want to hot-reload all events without interrupting timer
*/ */
export function updateAll(rundown: OntimeRundown) { export function updateAll(rundown: Rundown, timedEventsOrder: EntryId[]) {
const timedEvents = filterTimedEvents(rundown); const timedEvents = filterTimedEvents(rundown, timedEventsOrder);
loadNow(timedEvents); loadNow(timedEvents);
loadNext(timedEvents); loadNext(timedEvents);
updateLoaded(runtimeState.eventNow ?? undefined); updateLoaded(runtimeState.eventNow ?? undefined);
@@ -357,6 +357,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
if (state.timer.playback === Playback.Play) { if (state.timer.playback === Playback.Play) {
return false; return false;
} }
state.clock = timeNow(); state.clock = timeNow();
state.timer.secondaryTimer = null; state.timer.secondaryTimer = null;
@@ -541,7 +542,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 // 1. if an event is running, we simply take over the playback
if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) { if (runtimeState.timer.playback === Playback.Play && runtimeState.runtime.selectedEventIndex !== null) {
runtimeState.timer.playback = Playback.Roll; runtimeState.timer.playback = Playback.Roll;
@@ -598,7 +603,7 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
} }
// 3. if there is no event running, we need to find the next event // 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) { if (timedEvents.length === 0) {
throw new Error('No playable events found'); throw new Error('No playable events found');
} }
@@ -676,9 +681,8 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
/** /**
* handle block loading, not for use outside of runtimeState * 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) { if (state.eventNow === null) {
// we need a loaded event to have a block // we need a loaded event to have a block
state.currentBlock.block = null; state.currentBlock.block = null;
@@ -686,13 +690,19 @@ export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
return; return;
} }
const newCurrentBlock = getPreviousBlock(rundown, state.eventNow.id); const currentBlockId = state.eventNow.currentBlock;
// update time only if the block has changed // 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; state.currentBlock.startedAt = null;
} }
// update the block anyway // update the block anyway
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock }; if (currentBlockId === null) {
state.currentBlock.block = null;
return;
}
const currentBlock = rundown.entries[currentBlockId];
state.currentBlock.block = currentBlock as OntimeBlock;
} }
@@ -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
],
[],
];
File diff suppressed because it is too large Load Diff
@@ -1,45 +1,171 @@
import { import { CustomFields, OntimeBlock, OntimeEvent, Rundown, Settings, SupportedEvent, URLPreset } from 'ontime-types';
CustomFields,
DatabaseModel, import { defaultRundown } from '../../models/dataModel.js';
OntimeEvent,
OntimeRundown,
Settings,
SupportedEvent,
URLPreset,
} from 'ontime-types';
import { import {
parseCustomFields, parseCustomFields,
parseProject, parseProject,
parseRundown, parseRundown,
parseRundowns,
parseSettings, parseSettings,
parseUrlPresets, parseUrlPresets,
parseViewSettings, parseViewSettings,
sanitiseCustomFields, sanitiseCustomFields,
} from '../parserFunctions.js'; } from '../parserFunctions.js';
describe('parseRundown()', () => { describe('parseRundowns()', () => {
it('returns an empty array if no rundown is given', () => { it('returns a default project rundown if nothing is given', () => {
const errorEmitter = vi.fn(); const errorEmitter = vi.fn();
const result = parseRundown({}, errorEmitter); const result = parseRundowns({}, errorEmitter);
expect(result.rundown).toEqual([]);
expect(result.customFields).toEqual({}); 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); 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', () => { it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn(); const errorEmitter = vi.fn();
const rundown = [ const rundown = {
{ id: '1', type: SupportedEvent.Event, title: 'test', skip: false }, // OK id: '',
{ id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false }, // duplicate ID title: '',
{}, // no data order: ['1', '2', '3', '4'],
{ id: '2', title: 'test 2', skip: false }, // no type entries: {
] as OntimeRundown; '1': { id: '1', type: SupportedEvent.Event, title: 'test', skip: false } as OntimeEvent, // OK
const { rundown: parsedRundown } = parseRundown({ rundown, customFields: {} }, errorEmitter); '2': { id: '1', type: SupportedEvent.Block, title: 'test 2', skip: false } as OntimeBlock, // duplicate ID
expect(parsedRundown.length).toEqual(1); '3': {} as OntimeEvent, // no data
expect(parsedRundown.at(0)).toMatchObject({ id: '1', type: SupportedEvent.Event, title: 'test', skip: false }); '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(); 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()', () => { describe('parseProject()', () => {
@@ -48,34 +174,15 @@ describe('parseProject()', () => {
const result = parseProject({}, errorEmitter); const result = parseProject({}, errorEmitter);
expect(result).toBeTypeOf('object'); expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce(); expect(errorEmitter).toHaveBeenCalledOnce();
}); expect(result).toMatchObject({
title: '',
it('test migration with adding the logo field v3.8.0', () => { description: '',
const errorEmitter = vi.fn(); publicUrl: '',
const result = parseProject( publicInfo: '',
{ backstageUrl: '',
//@ts-expect-error -- checking migration when the logo field is added backstageInfo: '',
project: {
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
},
},
errorEmitter,
);
expect(result).toStrictEqual({
title: 'title',
description: 'description',
publicUrl: 'publicUrl',
publicInfo: 'publicInfo',
backstageUrl: 'backstageUrl',
backstageInfo: 'backstageInfo',
projectLogo: null, projectLogo: null,
}); });
expect(errorEmitter).not.toHaveBeenCalled();
}); });
}); });
@@ -85,9 +192,17 @@ describe('parseSettings()', () => {
}); });
it('returns an a base model as long as we have the app and version', () => { 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: { app: 'ontime', version: '1' } as Settings });
const result = parseSettings({ settings: minimalSettings });
expect(result).toBeTypeOf('object'); expect(result).toBeTypeOf('object');
expect(result).toMatchObject({
app: 'ontime',
version: expect.any(String),
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
language: 'en',
});
}); });
}); });
@@ -219,17 +334,6 @@ describe('sanitiseCustomFields()', () => {
expect(sanitationResult).toStrictEqual(expectedCustomFields); 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', () => { it('labels with space', () => {
const customFields: CustomFields = { const customFields: CustomFields = {
Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' }, Test_with_Space: { label: 'Test with Space', type: 'string', colour: 'red' },
@@ -260,100 +364,120 @@ describe('sanitiseCustomFields()', () => {
describe('parseRundown() linking', () => { describe('parseRundown() linking', () => {
it('returns linked events', () => { it('returns linked events', () => {
const data: Partial<DatabaseModel> = { const rundown: Rundown = {
rundown: [ id: '',
{ title: '',
revision: 1,
order: ['1', '2'],
entries: {
'1': {
id: '1', id: '1',
type: SupportedEvent.Event, type: SupportedEvent.Event,
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
{ '2': {
id: '2', id: '2',
type: SupportedEvent.Event, type: SupportedEvent.Event,
linkStart: 'true', linkStart: 'true',
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
], },
customFields: {},
}; };
const result = parseRundown(data); const result = parseRundown(rundown, {});
expect(result.rundown[1]).toMatchObject({ expect(result).toMatchObject({
id: '2', order: ['1', '2'],
linkStart: '1', entries: {
'2': {
linkStart: '1',
},
},
}); });
}); });
it('returns unlinked if no previous', () => { it('returns unlinked if no previous', () => {
const data: Partial<DatabaseModel> = { const rundown: Rundown = {
rundown: [ id: '',
{ title: '',
revision: 1,
order: ['1', '2'],
entries: {
'2': {
id: '2', id: '2',
type: SupportedEvent.Event, type: SupportedEvent.Event,
linkStart: 'true', linkStart: 'true',
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
], },
customFields: {},
}; };
const result = parseRundown(data); const result = parseRundown(rundown, {});
expect(result.rundown[0]).toMatchObject({ expect(result).toMatchObject({
id: '2', order: ['2'],
linkStart: null, entries: {
'2': {
linkStart: null,
},
},
}); });
}); });
it('returns linked events past blocks and delays', () => { it('returns linked events past blocks and delays', () => {
const data: Partial<DatabaseModel> = { const rundown: Rundown = {
rundown: [ id: '',
{ title: '',
revision: 1,
order: ['1', 'delay1', '2', 'block1', '3'],
entries: {
'1': {
id: '1', id: '1',
type: SupportedEvent.Event, type: SupportedEvent.Event,
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
{ delay1: {
id: 'delay1', id: 'delay1',
type: SupportedEvent.Delay, type: SupportedEvent.Delay,
duration: 0, duration: 0,
}, },
{ '2': {
id: '2', id: '2',
type: SupportedEvent.Event, type: SupportedEvent.Event,
linkStart: 'true', linkStart: 'true',
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
{ block1: {
id: 'block1', id: 'block1',
type: SupportedEvent.Block, type: SupportedEvent.Block,
title: '', title: '',
}, } as OntimeBlock,
{ '3': {
id: '3', id: '3',
type: SupportedEvent.Event, type: SupportedEvent.Event,
linkStart: 'true', linkStart: 'true',
skip: false, skip: false,
} as OntimeEvent, } as OntimeEvent,
], },
customFields: {},
}; };
const result = parseRundown(data); const result = parseRundown(rundown, {});
expect(result.rundown[0]).toMatchObject({ expect(result).toMatchObject({
id: '1', order: rundown.order,
cue: '1', entries: {
}); '1': {
// skip delay id: '1',
expect(result.rundown[2]).toMatchObject({ cue: '1',
id: '2', },
cue: '2', '2': {
linkStart: '1', id: '2',
}); cue: '2',
// skip block linkStart: '1',
expect(result.rundown[4]).toMatchObject({ },
id: '3', '3': {
cue: '3', id: '3',
linkStart: '2', cue: '3',
linkStart: '2',
},
},
}); });
}); });
}); });
+23 -53
View File
@@ -2,68 +2,38 @@ import { MILLIS_PER_MINUTE } from 'ontime-utils';
import { parseExcelDate } from '../time.js'; import { parseExcelDate } from '../time.js';
describe('parseExcelDate', () => { 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', () => { describe.todo('parses a valid date string as expected from excel', () => {
const testCases = [ test.each([
{ ['1899-12-30T00:00:00.000Z', 3600000],
fromExcel: '1899-12-30T00:00:00.000Z', ['1899-12-30T00:10:00.000Z', 4200000],
expected: 3600000, ['1899-12-30T01:00:00.000Z', 7200000],
}, ['1899-12-30T07:00:00.000Z', 28800000],
{ ['1899-12-30T08:00:10.000Z', 32410000],
fromExcel: '1899-12-30T00:10:00.000Z', ['1899-12-30T08:30:00.000Z', 34200000],
expected: 4200000, ])(`handles %s`, (fromExcel, expected) => {
}, expect(parseExcelDate(fromExcel)).toBe(expected);
{
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);
});
}); });
}); });
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', () => { describe('uses numeric fields as minutes', () => {
const invalidFields = [1, 10, 100]; test.each([[1], [10], [100]])(`handles numeric fields %s`, (fromExcel) => {
invalidFields.forEach((field) => { expect(parseExcelDate(fromExcel)).toBe(fromExcel * MILLIS_PER_MINUTE);
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(field * MILLIS_PER_MINUTE);
});
}); });
}); });
describe('returns 0 on other strings', () => { describe('returns 0 on other strings', () => {
const invalidFields = ['test', '']; test.each([['test'], [''], ['x']])(`handles invalid fields %s`, (fromExcel) => {
invalidFields.forEach((field) => { expect(parseExcelDate(fromExcel)).toBe(0);
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
}); });
}); });
}); });
+1 -1
View File
@@ -106,7 +106,7 @@ export async function copyDirectory(src: string, dest: string) {
} }
} }
/** /**
* workaround avoids origin errors in docker deployments * workaround avoids origin errors in docker deployments
* EXDEV cross-device link not permitted * EXDEV cross-device link not permitted
*/ */
+70 -47
View File
@@ -12,11 +12,12 @@ import {
import { import {
CustomFields, CustomFields,
DatabaseModel, DatabaseModel,
EventCustomFields, EntryCustomFields,
isOntimeBlock, isOntimeBlock,
LogOrigin, LogOrigin,
OntimeBlock,
OntimeEvent, OntimeEvent,
OntimeRundown, Rundown,
SupportedEvent, SupportedEvent,
TimerType, TimerType,
TimeStrategy, TimeStrategy,
@@ -27,17 +28,14 @@ import { logger } from '../classes/Logger.js';
import { event as eventDef } from '../models/eventsDefinition.js'; import { event as eventDef } from '../models/eventsDefinition.js';
import { makeString } from './parserUtils.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 { parseExcelDate } from './time.js';
import { Merge } from 'ts-essentials';
export type ErrorEmitter = (message: string) => void; export type ErrorEmitter = (message: string) => void;
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export const JSON_MIME = 'application/json'; export const JSON_MIME = 'application/json';
type ExcelData = Pick<DatabaseModel, 'rundown' | 'customFields'> & {
rundownMetadata: Record<string, { row: number; col: number }>;
};
function parseBooleanString(value: unknown): boolean { function parseBooleanString(value: unknown): boolean {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value; return value;
@@ -82,9 +80,14 @@ export function getCustomFieldData(
export const parseExcel = ( export const parseExcel = (
excelData: unknown[][], excelData: unknown[][],
existingCustomFields: CustomFields, existingCustomFields: CustomFields,
sheetName: string = 'Rundown from excel',
options?: Partial<ImportMap>, options?: Partial<ImportMap>,
): ExcelData => { ): {
const rundownMetadata = {}; rundown: Rundown;
customFields: CustomFields;
rundownMetadata: Record<string, { row: number; col: number }>;
} => {
const rundownMetadata: Record<string, { row: number; col: number }> = {};
const importMap: ImportMap = { ...defaultImportMap, ...options }; const importMap: ImportMap = { ...defaultImportMap, ...options };
for (const [key, value] of Object.entries(importMap)) { for (const [key, value] of Object.entries(importMap)) {
@@ -94,7 +97,13 @@ export const parseExcel = (
} }
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields); const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
const rundown: OntimeRundown = []; const rundown: Rundown = {
id: generateId(),
title: sheetName,
order: [],
entries: {},
revision: 0,
};
// title stuff: strings // title stuff: strings
let titleIndex: number | null = null; let titleIndex: number | null = null;
@@ -204,8 +213,8 @@ export const parseExcel = (
}, },
} as const; } as const;
const event: any = {}; const entry: Partial<Merge<OntimeEvent, OntimeBlock>> = {};
const eventCustomFields: EventCustomFields = {}; const entryCustomFields: EntryCustomFields = {};
for (let j = 0; j < row.length; j++) { for (let j = 0; j < row.length; j++) {
const column = row[j]; const column = row[j];
@@ -213,48 +222,50 @@ export const parseExcel = (
if (j === timerTypeIndex) { if (j === timerTypeIndex) {
const maybeTimeType = makeString(column, ''); const maybeTimeType = makeString(column, '');
if (maybeTimeType === 'block') { if (maybeTimeType === 'block') {
event.type = SupportedEvent.Block; // we leave this as a clue for the object filtering later on
entry.type = SupportedEvent.Block;
} else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) { } else if (maybeTimeType === '' || isKnownTimerType(maybeTimeType)) {
event.type = SupportedEvent.Event; // @ts-expect-error -- we leave this as a clue for the object filtering later on
event.timerType = validateTimerType(maybeTimeType); entry.type = SupportedEvent.Event;
entry.timerType = validateTimerType(maybeTimeType);
} else { } else {
// if it is not a block or a known type, we dont import it // if it is not a block or a known type, we dont import it
return; return;
} }
} else if (j === titleIndex) { } else if (j === titleIndex) {
event.title = makeString(column, ''); entry.title = makeString(column, '');
} else if (j === timeStartIndex) { } else if (j === timeStartIndex) {
event.timeStart = parseExcelDate(column); entry.timeStart = parseExcelDate(column);
} else if (j === linkStartIndex) { } else if (j === linkStartIndex) {
event.linkStart = parseBooleanString(column); entry.linkStart = parseBooleanString(column) ? 'true' : null;
} else if (j === timeEndIndex) { } else if (j === timeEndIndex) {
event.timeEnd = parseExcelDate(column); entry.timeEnd = parseExcelDate(column);
} else if (j === durationIndex) { } else if (j === durationIndex) {
event.duration = parseExcelDate(column); entry.duration = parseExcelDate(column);
} else if (j === cueIndex) { } else if (j === cueIndex) {
event.cue = makeString(column, ''); entry.cue = makeString(column, '');
} else if (j === countToEndIndex) { } else if (j === countToEndIndex) {
event.countToEnd = parseBooleanString(column); entry.countToEnd = parseBooleanString(column);
} else if (j === isPublicIndex) { } else if (j === isPublicIndex) {
event.isPublic = parseBooleanString(column); entry.isPublic = parseBooleanString(column);
} else if (j === skipIndex) { } else if (j === skipIndex) {
event.skip = parseBooleanString(column); entry.skip = parseBooleanString(column);
} else if (j === notesIndex) { } else if (j === notesIndex) {
event.note = makeString(column, ''); entry.note = makeString(column, '');
} else if (j === endActionIndex) { } else if (j === endActionIndex) {
event.endAction = validateEndAction(column); entry.endAction = validateEndAction(column);
} else if (j === timeWarningIndex) { } else if (j === timeWarningIndex) {
event.timeWarning = parseExcelDate(column); entry.timeWarning = parseExcelDate(column);
} else if (j === timeDangerIndex) { } else if (j === timeDangerIndex) {
event.timeDanger = parseExcelDate(column); entry.timeDanger = parseExcelDate(column);
} else if (j === colourIndex) { } else if (j === colourIndex) {
event.colour = makeString(column, ''); entry.colour = makeString(column, '');
} else if (j === entryIdIndex) { } else if (j === entryIdIndex) {
event.id = encodeURIComponent(makeString(column, undefined)); entry.id = encodeURIComponent(makeString(column, undefined));
} else if (j in customFieldIndexes) { } else if (j in customFieldIndexes) {
const importKey = customFieldIndexes[j]; const importKey = customFieldIndexes[j];
const ontimeKey = customFieldImportKeys[importKey]; const ontimeKey = customFieldImportKeys[importKey];
eventCustomFields[ontimeKey] = makeString(column, ''); entryCustomFields[ontimeKey] = makeString(column, '');
} else { } else {
// 2. if there is no flag, lets see if we know the field type // 2. if there is no flag, lets see if we know the field type
if (typeof column === 'string') { if (typeof column === 'string') {
@@ -280,20 +291,32 @@ export const parseExcel = (
} }
} }
// if any data was found in row, push to array // if we didnt find any keys (empty row, or some other data), skip making an event
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length; const keysFound = Object.keys(entry).length + Object.keys(entryCustomFields).length;
if (keysFound > 0) { if (keysFound === 0) {
// if it is a Block type drop all other filed return;
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 } });
}
} }
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 { return {
@@ -325,11 +348,10 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: Da
}; };
// we need to parse the custom fields first so they can be used in validating events // 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 { rundowns, customFields } = parseRundowns(jsonData, makeEmitError('Rundown'));
const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown'));
const data: DatabaseModel = { const data: DatabaseModel = {
rundown, rundowns,
project: parseProject(jsonData, makeEmitError('Project')), project: parseProject(jsonData, makeEmitError('Project')),
settings, settings,
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')), viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
@@ -392,6 +414,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
gap: 0, // is always regenerated by the cache gap: 0, // is always regenerated by the cache
// short circuit empty string // short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue), cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
currentBlock: originalEvent.currentBlock,
revision: originalEvent.revision, revision: originalEvent.revision,
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning, timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger, timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
+80 -24
View File
@@ -5,8 +5,9 @@ import {
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEvent, OntimeEvent,
OntimeRundown,
ProjectData, ProjectData,
ProjectRundowns,
Rundown,
Settings, Settings,
URLPreset, URLPreset,
ViewSettings, ViewSettings,
@@ -14,45 +15,86 @@ import {
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
} from 'ontime-types'; } from 'ontime-types';
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace } from 'ontime-utils'; import { customFieldLabelToKey, generateId, isAlphanumericWithSpace, isObjectEmpty } from 'ontime-utils';
import { dbModel } from '../models/dataModel.js'; import { dbModel, defaultRundown } from '../models/dataModel.js';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
import { createEvent, type ErrorEmitter } from './parser.js'; import { createEvent, type ErrorEmitter } from './parser.js';
/** /**
* Parse rundown array of an entry * Parse a rundowns object along with the project custom fields
*/ */
export function parseRundown( export function parseRundowns(
data: Partial<DatabaseModel>, data: Partial<DatabaseModel>,
emitError?: ErrorEmitter, emitError?: ErrorEmitter,
): { customFields: CustomFields; rundown: OntimeRundown } { ): { customFields: CustomFields; rundowns: ProjectRundowns } {
// check custom fields first // check custom fields first
const parsedCustomFields = parseCustomFields(data, emitError); const parsedCustomFields = parseCustomFields(data, emitError);
if (!data.rundown) { if (!data.rundowns || isObjectEmpty(data.rundowns)) {
emitError?.('No data found to import'); 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<CustomFields>,
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 eventIndex = 0;
let previousId: string | null = null; let previousId: string | null = null;
const ids: string[] = [];
for (const event of data.rundown) { for (let i = 0; i < rundown.order.length; i++) {
if (ids.includes(event.id)) { 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'); emitError?.('ID collision on event import, skipping');
continue; continue;
} }
const id = event.id || generateId(); const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
const maybeEvent = { ...event, id }; const maybeEvent = { ...event };
if (event.linkStart) { if (event.linkStart) {
maybeEvent.linkStart = previousId; maybeEvent.linkStart = previousId;
@@ -78,20 +120,29 @@ export function parseRundown(
} else if (isOntimeDelay(event)) { } else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id }; newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeBlock(event)) { } 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 { } else {
emitError?.('Unknown event type, skipping'); emitError?.('Unknown event type, skipping');
continue; continue;
} }
if (newEvent) { if (newEvent) {
rundown.push(newEvent); parsedRundown.entries[id] = newEvent;
ids.push(id); parsedRundown.order.push(id);
} }
} }
console.log(`Uploaded rundown with ${rundown.length} entries`); console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
return { customFields: parsedCustomFields, rundown }; return parsedRundown;
} }
/** /**
@@ -215,10 +266,15 @@ export function sanitiseCustomFields(data: object): CustomFields {
continue; continue;
} }
const keyFromLabel = customFieldLabelToKey(field.label); // Test label and key cohesion
// Test label and key cohesion, but allow old lowercased keys to stay const key = (() => {
// TODO: the `toLocaleLowerCase` part here is to conserve keys from old projects and could be removed at some point (okt. 2024) const keyFromLabel = customFieldLabelToKey(field.label);
const key = originalKey.toLocaleLowerCase() === keyFromLabel.toLocaleLowerCase() ? originalKey : keyFromLabel; if (keyFromLabel === null) {
return originalKey;
}
return originalKey.toLowerCase() === keyFromLabel.toLowerCase() ? originalKey : keyFromLabel;
})();
if (key in newCustomFields) { if (key in newCustomFields) {
continue; continue;
} }
+481 -420
View File
@@ -1,408 +1,469 @@
{ {
"rundown": [ "rundowns": {
{ "demo": {
"id": "32d31", "id": "demo",
"type": "event", "title": "Eurovision Demo",
"title": "Albania", "order": [
"timeStart": 36000000, "32d31",
"timeEnd": 37200000, "21cd2",
"duration": 1200000, "0b371",
"timeStrategy": "lock-duration", "3cd28",
"linkStart": null, "e457f",
"endAction": "none", "01e85",
"timerType": "count-down", "1c420",
"countToEnd": false, "b7737",
"isPublic": true, "d3a80",
"skip": false, "8276c",
"note": "SF1.01", "2340b",
"colour": "", "cb90b",
"delay": 0, "503c4",
"dayOffset": 0, "5e965",
"gap": 0, "bab4a",
"cue": "SF1.01", "d3eb1"
"revision": 0, ],
"timeWarning": 120000, "entries": {
"timeDanger": 60000, "32d31": {
"custom": { "type": "event",
"song": "Sekret", "id": "32d31",
"artist": "Ronela Hajati" "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": { "project": {
"title": "Eurovision Song Contest", "title": "Eurovision Song Contest",
"description": "Turin 2022", "description": "Turin 2022",
@@ -414,7 +475,7 @@
}, },
"settings": { "settings": {
"app": "ontime", "app": "ontime",
"version": "3.10.2", "version": "-",
"serverPort": 4001, "serverPort": 4001,
"editorKey": null, "editorKey": null,
"operatorKey": null, "operatorKey": null,
@@ -422,12 +483,24 @@
"language": "en" "language": "en"
}, },
"viewSettings": { "viewSettings": {
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"dangerColor": "#ED3333", "dangerColor": "#ED3333",
"endMessage": "", "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": [ "urlPresets": [
{ {
@@ -442,17 +515,5 @@
"oscPortIn": 8888, "oscPortIn": 8888,
"triggers": [], "triggers": [],
"automations": {} "automations": {}
},
"customFields": {
"song": {
"type": "string",
"colour": "",
"label": "song"
},
"artist": {
"type": "string",
"colour": "",
"label": "artist"
}
} }
} }
+1 -1
View File
@@ -12,7 +12,7 @@ It can be user either on its own, or as in conjunction with manual playback to a
## Implementation details ## Implementation details
### starting to roll ### 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. 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.
+3 -3
View File
@@ -2,8 +2,8 @@ import { test, expect } from '@playwright/test';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
const fileToUpload = 'e2e/tests/fixtures/test-db.json'; const fileToUpload = 'e2e/tests/fixtures/e2e-test-db.json';
const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json'; const fileToDownload = 'e2e/tests/fixtures/tmp/e2e-test-db.json';
test('project file upload', async ({ page }) => { test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor'); await page.goto('http://localhost:4001/editor');
@@ -46,7 +46,7 @@ test('project file download', async ({ page }) => {
const downloadPromise = page.waitForEvent('download'); const downloadPromise = page.waitForEvent('download');
await page await page
.getByRole('row', { name: RegExp('^test-db') }) .getByRole('row', { name: /^e2e-test-db/ })
.getByLabel('Options') .getByLabel('Options')
.click(); .click();
await page.getByRole('menuitem', { name: 'Download' }).click(); await page.getByRole('menuitem', { name: 'Download' }).click();
+519
View File
@@ -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": {}
}
}
-458
View File
@@ -1,458 +0,0 @@
{
"rundown": [
{
"id": "32d31",
"type": "event",
"title": "Albania",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.01",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.01",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
},
{
"id": "21cd2",
"type": "event",
"title": "Latvia",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.02",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.02",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"id": "0b371",
"type": "event",
"title": "Lithuania",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.03",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.03",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"id": "3cd28",
"type": "event",
"title": "Switzerland",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.04",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.04",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"id": "e457f",
"type": "event",
"title": "Slovenia",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.05",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.05",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"id": "1c420",
"type": "event",
"title": "Ukraine",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.06",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.06",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"id": "b7737",
"type": "event",
"title": "Bulgaria",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.07",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.07",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"id": "d3a80",
"type": "event",
"title": "Netherlands",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.08",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.08",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"id": "8276c",
"type": "event",
"title": "Moldova",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.09",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.09",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"id": "2340b",
"type": "event",
"title": "Portugal",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.10",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.10",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"id": "503c4",
"type": "event",
"title": "Croatia",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.11",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.11",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"id": "5e965",
"type": "event",
"title": "Denmark",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.12",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.12",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"id": "bab4a",
"type": "event",
"title": "Austria",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.13",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.13",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"id": "d3eb1",
"type": "event",
"title": "Greece",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.14",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.14",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
}
],
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
"publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null
},
"settings": {
"app": "ontime",
"version": "3.10.2",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
"language": "en"
},
"viewSettings": {
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"dangerColor": "#ED3333",
"endMessage": "",
"freezeEnd": false
},
"urlPresets": [
{
"enabled": true,
"alias": "test",
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
}
],
"automation": {
"enabledAutomations": false,
"enabledOscIn": true,
"oscPortIn": 8888,
"triggers": [],
"automations": {}
},
"customFields": {
"song": {
"type": "string",
"colour": "",
"label": "song"
},
"artist": {
"type": "string",
"colour": "",
"label": "artist"
}
}
}
+3 -2
View File
@@ -32,10 +32,11 @@
"dist-win": "turbo run dist-win", "dist-win": "turbo run dist-win",
"dist-mac": "turbo run dist-mac", "dist-mac": "turbo run dist-mac",
"dist-linux": "turbo run dist-linux", "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:ui": "cross-env DEBUG=pw:webserver npx playwright test --ui -c playwright.config.ts",
"e2e:i": "npx playwright codegen", "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": { "devDependencies": {
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
@@ -1,17 +1,8 @@
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js'; import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js';
import type { OntimeRundownEntry } from '../../definitions/core/Rundown.type.js'; import type { OntimeEntry } from '../../definitions/core/Rundown.type.js';
type EventId = string;
export type NormalisedRundown = Record<EventId, OntimeRundownEntry>;
export interface RundownCached {
rundown: NormalisedRundown;
order: EventId[];
revision: number;
}
export type PatchWithId = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & { id: string }; export type PatchWithId = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & { id: string };
export type EventPostPayload = Partial<OntimeRundownEntry> & { export type EventPostPayload = Partial<OntimeEntry> & {
after?: string; after?: string;
before?: string; before?: string;
}; };
@@ -20,3 +11,10 @@ export type TransientEventPayload = Partial<OntimeEvent | OntimeDelay | OntimeBl
after?: string; after?: string;
before?: string; before?: string;
}; };
export type ProjectRundownsList = {
id: string;
title: string;
numEntries: number;
revision: number;
}[];
@@ -1,15 +1,15 @@
import type { import type {
AutomationSettings, AutomationSettings,
CustomFields, CustomFields,
OntimeRundown,
ProjectData, ProjectData,
ProjectRundowns,
Settings, Settings,
URLPreset, URLPreset,
ViewSettings, ViewSettings,
} from '../index.js'; } from '../index.js';
export type DatabaseModel = { export type DatabaseModel = {
rundown: OntimeRundown; rundowns: ProjectRundowns;
project: ProjectData; project: ProjectData;
settings: Settings; settings: Settings;
viewSettings: ViewSettings; viewSettings: ViewSettings;
@@ -7,4 +7,4 @@ export type CustomField = {
}; };
export type CustomFields = Record<CustomFieldLabel, CustomField>; export type CustomFields = Record<CustomFieldLabel, CustomField>;
export type EventCustomFields = Record<CustomFieldLabel, string>; export type EntryCustomFields = Record<CustomFieldLabel, string>;
@@ -1,4 +1,6 @@
import type { EndAction, EventCustomFields, MaybeString, TimerType, TimeStrategy } from '../../index.js'; import type { EndAction, EntryCustomFields, MaybeNumber, MaybeString, TimerType, TimeStrategy } from '../../index.js';
export type EntryId = string;
export enum SupportedEvent { export enum SupportedEvent {
Event = 'event', Event = 'event',
@@ -8,7 +10,7 @@ export enum SupportedEvent {
export type OntimeBaseEvent = { export type OntimeBaseEvent = {
type: SupportedEvent; type: SupportedEvent;
id: string; id: EntryId;
}; };
export type OntimeDelay = OntimeBaseEvent & { export type OntimeDelay = OntimeBaseEvent & {
@@ -19,6 +21,18 @@ export type OntimeDelay = OntimeBaseEvent & {
export type OntimeBlock = OntimeBaseEvent & { export type OntimeBlock = OntimeBaseEvent & {
type: SupportedEvent.Block; type: SupportedEvent.Block;
title: string; 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 & { export type OntimeEvent = OntimeBaseEvent & {
@@ -37,13 +51,15 @@ export type OntimeEvent = OntimeBaseEvent & {
isPublic: boolean; isPublic: boolean;
skip: boolean; skip: boolean;
colour: string; colour: string;
timeWarning: number;
timeDanger: number;
custom: EntryCustomFields;
// !==== RUNTIME METADATA ====! //
currentBlock: EntryId | null;
revision: number; revision: number;
delay: number; // calculated at runtime delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime dayOffset: number; // calculated at runtime
gap: number; // calculated at runtime gap: number; // calculated at runtime
timeWarning: number;
timeDanger: number;
custom: EventCustomFields;
}; };
export type PlayableEvent = OntimeEvent & { skip: false }; export type PlayableEvent = OntimeEvent & { skip: false };
@@ -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 OntimeEntry = OntimeDelay | OntimeBlock | OntimeEvent;
export type OntimeRundown = OntimeRundownEntry[]; export type RundownEntries = Record<EntryId, OntimeEntry>;
// we need to create a manual union type since keys cannot be used in type unions // 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 OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock;
export type ProjectRundowns = Record<string, Rundown>;
export type Rundown = {
id: string;
title: string;
order: EntryId[];
entries: RundownEntries;
revision: number;
};
+10 -4
View File
@@ -4,6 +4,7 @@ export type { DatabaseModel } from './definitions/DataModel.type.js';
// ---> Rundown // ---> Rundown
export { EndAction } from './definitions/EndAction.type.js'; export { EndAction } from './definitions/EndAction.type.js';
export { export {
type EntryId,
type OntimeBaseEvent, type OntimeBaseEvent,
type OntimeDelay, type OntimeDelay,
type OntimeBlock, type OntimeBlock,
@@ -12,7 +13,13 @@ export {
type TimeField, type TimeField,
SupportedEvent, SupportedEvent,
} from './definitions/core/OntimeEvent.type.js'; } 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 { TimeStrategy } from './definitions/TimeStrategy.type.js';
export { TimerType } from './definitions/TimerType.type.js'; export { TimerType } from './definitions/TimerType.type.js';
@@ -53,7 +60,7 @@ export type {
CustomFields, CustomFields,
CustomField, CustomField,
CustomFieldLabel, CustomFieldLabel,
EventCustomFields, EntryCustomFields,
} from './definitions/core/CustomFields.type.js'; } from './definitions/core/CustomFields.type.js';
// SERVER RESPONSES // SERVER RESPONSES
@@ -73,9 +80,8 @@ export type {
export type { QuickStartData } from './api/db/db.type.js'; export type { QuickStartData } from './api/db/db.type.js';
export type { export type {
EventPostPayload, EventPostPayload,
NormalisedRundown,
PatchWithId, PatchWithId,
RundownCached, ProjectRundownsList,
TransientEventPayload, TransientEventPayload,
} from './api/rundown-controller/BackendResponse.type.js'; } from './api/rundown-controller/BackendResponse.type.js';
+2 -2
View File
@@ -1,11 +1,11 @@
import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js'; 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 type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
import { SupportedEvent } 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 { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js'; import type { TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js';
import { TimerLifeCycle } from '../definitions/core/TimerLifecycle.type.js'; import { TimerLifeCycle } from '../definitions/core/TimerLifecycle.type.js';
type MaybeEvent = OntimeRundownEntry | Partial<OntimeRundownEntry> | null | undefined; type MaybeEvent = OntimeEntry | Partial<OntimeEntry> | null | undefined;
export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent { export function isOntimeEvent(event: MaybeEvent): event is OntimeEvent {
return event?.type === SupportedEvent.Event; return event?.type === SupportedEvent.Event;
+1 -4
View File
@@ -8,10 +8,7 @@ export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js'; export { generateId } from './src/generate-id/generateId.js';
export { export {
filterPlayable,
filterTimedEvents,
getEventWithId, getEventWithId,
getFirst,
getFirstEvent, getFirstEvent,
getFirstEventNormal, getFirstEventNormal,
getFirstNormal, getFirstNormal,
@@ -66,7 +63,7 @@ export { deepmerge } from './src/externals/deepmerge.js';
// array utils // array utils
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js'; export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils // object utils
export { getPropertyFromPath } from './src/common/objectUtils.js'; export { getPropertyFromPath, isObjectEmpty } from './src/common/objectUtils.js';
// generic utilities // generic utilities
export { getErrorMessage } from './src/generic/generic.js'; export { getErrorMessage } from './src/generic/generic.js';
+2 -5
View File
@@ -30,17 +30,14 @@ export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
* @param index * @param index
* @param array * @param array
*/ */
export function deleteAtIndex<T>(index: number, array: T[]) { export function deleteAtIndex<T>(index: number, array: T[]): T[] {
return array.toSpliced(index, 1); return array.toSpliced(index, 1);
} }
/** /**
* Reorders two objects in an array * Reorders two objects in an array
* @param array
* @param fromIndex
* @param toIndex
*/ */
export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number) { export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number): T[] {
if (fromIndex === toIndex) { if (fromIndex === toIndex) {
return array; // No change needed, return the original array return array; // No change needed, return the original array
} }

Some files were not shown because too many files have changed in this diff Show More