mirror of
https://github.com/cpvalente/ontime.git
synced 2026-07-26 10:38:55 +00:00
refactor: restructure model to contain an object of rundowns
This commit is contained in:
committed by
Carlos Valente
parent
351425127a
commit
69eb9a5eff
@@ -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;');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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();
|
||||
};
|
||||
|
||||
+32
-31
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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;
|
||||
}
|
||||
|
||||
+13
-13
@@ -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,
|
||||
|
||||
+2
-2
@@ -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}`]);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+519
@@ -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": {}
|
||||
}
|
||||
}
|
||||
Vendored
-459
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user