refactor: restructure model to contain an object of rundowns

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