mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 07:29:08 +00:00
refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry * refactor: migrate edit mutations to transaction * refactor: migrate delete mutation to transaction * refactor: migrate reorder mutation to transaction * refactor: migrate apply delay to transaction * refactor: migrate swapEvents to transaction * refactor: migrate clone to transaction * refactor: migrate group/ungroup transactions * refactor: simplify mutations * refactor: migrate getters * chore: add tests to processRundown()
This commit is contained in:
@@ -51,7 +51,7 @@ type BatchEditEntry = {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to edit multiple events
|
* HTTP request to edit multiple events
|
||||||
*/
|
*/
|
||||||
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<MessageResponse>> {
|
export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.put(`${rundownPath}/batch`, data);
|
return axios.put(`${rundownPath}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
|
|||||||
/**
|
/**
|
||||||
* HTTP request to request application of delay
|
* HTTP request to request application of delay
|
||||||
*/
|
*/
|
||||||
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<MessageResponse>> {
|
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -389,8 +389,18 @@ export const useEntryActions = () => {
|
|||||||
// Return a context with the previous rundown
|
// Return a context with the previous rundown
|
||||||
return { previousRundown };
|
return { previousRundown };
|
||||||
},
|
},
|
||||||
onSettled: async () => {
|
onSuccess: (response) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onError: (_error, _newEvent, context) => {
|
onError: (_error, _newEvent, context) => {
|
||||||
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
|
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
|
||||||
@@ -521,6 +531,19 @@ export const useEntryActions = () => {
|
|||||||
*/
|
*/
|
||||||
const _applyDelayMutation = useMutation({
|
const _applyDelayMutation = useMutation({
|
||||||
mutationFn: requestApplyDelay,
|
mutationFn: requestApplyDelay,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (!response.data) return;
|
||||||
|
|
||||||
|
const { id, title, order, flatOrder, entries, revision } = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(RUNDOWN, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
order,
|
||||||
|
flatOrder,
|
||||||
|
entries,
|
||||||
|
revision,
|
||||||
|
});
|
||||||
|
},
|
||||||
// Mutation finished, failed or successful
|
// Mutation finished, failed or successful
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
queryClient.invalidateQueries({ queryKey: RUNDOWN });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
|
||||||
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
|
||||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||||
|
|||||||
@@ -3,15 +3,13 @@ import { CustomField, CustomFields, ErrorResponse } from 'ontime-types';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
import {
|
|
||||||
createCustomField,
|
import { createCustomField, editCustomField, removeCustomField } from '../../services/rundown-service/rundownCache.js';
|
||||||
editCustomField,
|
|
||||||
getCustomFields as getCustomFieldsFromCache,
|
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||||
removeCustomField,
|
|
||||||
} from '../../services/rundown-service/rundownCache.js';
|
|
||||||
|
|
||||||
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
|
export async function getCustomFields(_req: Request, res: Response<CustomFields>) {
|
||||||
const customFields = getCustomFieldsFromCache();
|
const customFields = getProjectCustomFields();
|
||||||
res.json(customFields);
|
res.json(customFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import type { WorkBook } from 'xlsx';
|
|||||||
import { parseExcel } from '../../utils/parser.js';
|
import { parseExcel } from '../../utils/parser.js';
|
||||||
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
import { parseCustomFields } from '../../utils/parserFunctions.js';
|
||||||
import { deleteFile } from '../../utils/parserUtils.js';
|
import { deleteFile } from '../../utils/parserUtils.js';
|
||||||
import { getCustomFields } from '../../services/rundown-service/rundownCache.js';
|
|
||||||
|
|
||||||
import { parseRundown } from '../rundown/rundown.parser.js';
|
import { parseRundown } from '../rundown/rundown.parser.js';
|
||||||
|
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||||
|
|
||||||
let excelData: WorkBook = xlsx.utils.book_new();
|
let excelData: WorkBook = xlsx.utils.book_new();
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function generateRundownPreview(options: ImportMap): { rundown: Rundown;
|
|||||||
|
|
||||||
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
|
const arrayOfData: unknown[][] = xlsx.utils.sheet_to_json(data, { header: 1, blankrows: false, raw: false });
|
||||||
|
|
||||||
const dataFromExcel = parseExcel(arrayOfData, getCustomFields(), options.worksheet, options);
|
const dataFromExcel = parseExcel(arrayOfData, getProjectCustomFields(), options.worksheet, options);
|
||||||
const parsedCustomFields = parseCustomFields(dataFromExcel);
|
const parsedCustomFields = parseCustomFields(dataFromExcel);
|
||||||
|
|
||||||
// we run the parsed data through an extra step to ensure the objects shape
|
// we run the parsed data through an extra step to ensure the objects shape
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { SupportedEntry, OntimeEvent, OntimeDelay, OntimeBlock, Rundown } from 'ontime-types';
|
||||||
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
|
|
||||||
|
const baseEvent = {
|
||||||
|
type: SupportedEntry.Event,
|
||||||
|
skip: false,
|
||||||
|
revision: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseBlock = {
|
||||||
|
type: SupportedEntry.Block,
|
||||||
|
events: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to create a Ontime event
|
||||||
|
*/
|
||||||
|
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
|
||||||
|
return {
|
||||||
|
...baseEvent,
|
||||||
|
...patch,
|
||||||
|
} as OntimeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to create a delay event
|
||||||
|
*/
|
||||||
|
export function makeOntimeDelay(patch: Partial<OntimeDelay>): OntimeDelay {
|
||||||
|
return { id: 'delay', type: SupportedEntry.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility to generate a rundown of OntimeEvents form partial objects
|
||||||
|
*/
|
||||||
|
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
|
||||||
|
return events.map(makeOntimeEvent);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
import { SupportedEntry, OntimeEvent, OntimeBlock, Rundown } from 'ontime-types';
|
||||||
|
|
||||||
import { defaultRundown } from '../../../models/dataModel.js';
|
import { defaultRundown } from '../../../models/dataModel.js';
|
||||||
import { makeOntimeBlock, makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
import { makeOntimeBlock, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { parseRundowns, parseRundown } from '../rundown.parser.js';
|
import { parseRundowns, parseRundown } from '../rundown.parser.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { createEvent } from '../rundown.utils.js';
|
import { createEvent, deleteById, doesInvalidateMetadata, hasChanges } from '../rundown.utils.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
it('validates a good object', () => {
|
it('validates a good object', () => {
|
||||||
@@ -78,3 +80,79 @@ describe('test event validator', () => {
|
|||||||
expect(typeof validated.title).toEqual('string');
|
expect(typeof validated.title).toEqual('string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('doesInvalidateMetadata()', () => {
|
||||||
|
it('is stale if data contains timers', () => {
|
||||||
|
const needsRecompute = [
|
||||||
|
{ timeStart: 10 },
|
||||||
|
{ timeEnd: 10 },
|
||||||
|
{ duration: 10 },
|
||||||
|
{ linkStart: true },
|
||||||
|
{ timerStrategy: TimeStrategy.LockDuration },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of needsRecompute) {
|
||||||
|
expect(doesInvalidateMetadata(testCase)).toBe(true);
|
||||||
|
}
|
||||||
|
expect.assertions(needsRecompute.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not stale if data contains auxiliary dataset', () => {
|
||||||
|
expect(
|
||||||
|
doesInvalidateMetadata({
|
||||||
|
cue: 'cue',
|
||||||
|
title: 'title',
|
||||||
|
note: 'note',
|
||||||
|
endAction: EndAction.LoadNext,
|
||||||
|
timerType: TimerType.Clock,
|
||||||
|
isPublic: false,
|
||||||
|
colour: 'colour',
|
||||||
|
timeWarning: 1,
|
||||||
|
timeDanger: 2,
|
||||||
|
custom: {
|
||||||
|
lighting: '3',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasChanges()', () => {
|
||||||
|
it('identifies objects with new values', () => {
|
||||||
|
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
|
||||||
|
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||||
|
expect(hasChanges(existing, newEvent)).toBe(true);
|
||||||
|
});
|
||||||
|
it('identifies objects with all same values', () => {
|
||||||
|
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
|
||||||
|
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||||
|
expect(hasChanges(existing, newEvent)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteById', () => {
|
||||||
|
it('should delete the first instance of the specified ID from the array', () => {
|
||||||
|
const array = ['id1', 'id2', 'id3', 'id4'];
|
||||||
|
const result = deleteById(array, 'id2');
|
||||||
|
expect(result).toStrictEqual(['id1', 'id3', 'id4']);
|
||||||
|
expect(result).not.toBe(array); // Ensure a new array is returned
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify the array if the specified ID does not exist', () => {
|
||||||
|
const array = ['id1', 'id2', 'id3', 'id4'];
|
||||||
|
const result = deleteById(array, 'id5');
|
||||||
|
expect(result).toStrictEqual(['id1', 'id2', 'id3', 'id4']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same array if it is empty', () => {
|
||||||
|
const array: string[] = [];
|
||||||
|
const result = deleteById(array, 'id1');
|
||||||
|
expect(result).toStrictEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scenarios where the delete id is not found', () => {
|
||||||
|
const array = ['id1', 'id2', 'id3'];
|
||||||
|
const result = deleteById(array, 'id4');
|
||||||
|
expect(result).toStrictEqual(['id1', 'id2', 'id3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
|
||||||
import {
|
|
||||||
addEvent,
|
|
||||||
applyDelay,
|
|
||||||
batchEditEvents,
|
|
||||||
deleteAllEntries,
|
|
||||||
deleteEvent,
|
|
||||||
editEvent,
|
|
||||||
ungroupEntries,
|
|
||||||
groupEntries,
|
|
||||||
swapEvents,
|
|
||||||
cloneEntry,
|
|
||||||
} from '../../services/rundown-service/RundownService.js';
|
|
||||||
import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js';
|
|
||||||
|
|
||||||
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 rundownGetCurrent(_req: Request, res: Response<Rundown>) {
|
|
||||||
const cachedRundown = getCurrentRundown();
|
|
||||||
res.json(cachedRundown);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownGetById(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
|
|
||||||
const { eventId } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const event = getEntryWithId(eventId);
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
res.status(404).send({ message: 'Event not found' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(200).json(event);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(500).json({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownPost(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
|
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newEvent = await addEvent(req.body);
|
|
||||||
res.status(201).send(newEvent);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownPut(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
|
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const event = await editEvent(req.body);
|
|
||||||
res.status(200).send(event);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownBatchPut(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return res.status(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { data, ids } = req.body;
|
|
||||||
await batchEditEvents(ids, data);
|
|
||||||
res.status(200).send({ message: 'Batch edit successful' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
if (failEmptyObjects(req.body, res)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { from, to } = req.body;
|
|
||||||
await swapEvents(from, to);
|
|
||||||
res.status(200).send({ message: 'Swap successful' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
await applyDelay(req.params.entryId);
|
|
||||||
res.status(200).send({ message: 'Delay applied' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownCloneEntry(req: Request, res: Response<Rundown | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const newRundown = await cloneEntry(req.params.entryId);
|
|
||||||
res.status(200).send(newRundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownUngroupEntries(req: Request, res: Response<Rundown | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const newRundown = await ungroupEntries(req.params.entryId);
|
|
||||||
res.status(200).send(newRundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownAddToBlock(req: Request, res: Response<Rundown | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
const newRundown = await groupEntries(req.body.ids);
|
|
||||||
res.status(200).send(newRundown);
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
await deleteAllEntries();
|
|
||||||
res.status(204).send({ message: 'All events deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletesEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
|
||||||
try {
|
|
||||||
await deleteEvent(req.body.ids);
|
|
||||||
res.status(204).send({ message: 'Events deleted' });
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
res.status(400).send({ message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
/**
|
||||||
|
* This module handles interfacing with the stored rundown
|
||||||
|
* Additionally it provides a transaction-like interface on a caching layer
|
||||||
|
*
|
||||||
|
* The mutation functions mutate the rundown in place
|
||||||
|
* This is to simplify the logic and avoid multiple copies of the objects
|
||||||
|
*
|
||||||
|
* The mutations assume that the data has been validated
|
||||||
|
* - in shape
|
||||||
|
* - in domain
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CustomFields,
|
||||||
|
EntryId,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeEvent,
|
||||||
|
isPlayableEvent,
|
||||||
|
OntimeBlock,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
PatchWithId,
|
||||||
|
Rundown,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { insertAtIndex } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { makeRundownMetadata, ProcessedRundownMetadata } from '../../services/rundown-service/rundownCache.utils.js';
|
||||||
|
import { customFieldChangelog } from '../../services/rundown-service/rundownCache.js';
|
||||||
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
|
import type { RundownMetadata } from './rundown.types.js';
|
||||||
|
import {
|
||||||
|
applyPatchToEntry,
|
||||||
|
cloneBlock,
|
||||||
|
cloneEntry,
|
||||||
|
createBlock,
|
||||||
|
deleteById,
|
||||||
|
doesInvalidateMetadata,
|
||||||
|
getUniqueId,
|
||||||
|
} from './rundown.utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The currently loaded rundown in cache
|
||||||
|
*/
|
||||||
|
const cachedRundown: Rundown = {
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
order: [],
|
||||||
|
flatOrder: [], // TODO: remove in favour of the metadata flatEntryOrder
|
||||||
|
entries: {},
|
||||||
|
revision: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let rundownMetadata: RundownMetadata = {
|
||||||
|
totalDelay: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalDays: 0,
|
||||||
|
firstStart: null,
|
||||||
|
lastEnd: null,
|
||||||
|
|
||||||
|
playableEventOrder: [],
|
||||||
|
timedEventOrder: [],
|
||||||
|
flatEntryOrder: [],
|
||||||
|
|
||||||
|
assignedCustomFields: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The custom fields that are used in the project
|
||||||
|
* Not unique to the loaded rundown
|
||||||
|
*/
|
||||||
|
let projectCustomFields: CustomFields = {};
|
||||||
|
|
||||||
|
export const getCurrentRundown = (): Readonly<Rundown> => cachedRundown;
|
||||||
|
export const getProjectCustomFields = (): Readonly<CustomFields> => projectCustomFields;
|
||||||
|
|
||||||
|
export function createTransaction() {
|
||||||
|
const rundown = structuredClone(cachedRundown);
|
||||||
|
const customFields = projectCustomFields;
|
||||||
|
|
||||||
|
function commit(shouldProcess: boolean = true) {
|
||||||
|
// schedule a database update
|
||||||
|
setImmediate(async () => {
|
||||||
|
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||||
|
});
|
||||||
|
|
||||||
|
const revision = rundown.revision + 1;
|
||||||
|
cachedRundown.revision = revision;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some mutations do not require processing the rundown
|
||||||
|
* We simply increment the revision and return the rundown
|
||||||
|
*/
|
||||||
|
if (!shouldProcess) {
|
||||||
|
cachedRundown.entries = rundown.entries;
|
||||||
|
cachedRundown.order = rundown.order;
|
||||||
|
cachedRundown.flatOrder = rundown.flatOrder;
|
||||||
|
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedData = processRundown(rundown, projectCustomFields);
|
||||||
|
// update the cache values
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
|
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||||
|
cachedRundown.entries = entries;
|
||||||
|
cachedRundown.order = order;
|
||||||
|
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||||
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
|
return { rundown, rundownMetadata, customFields: projectCustomFields, revision: cachedRundown.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customFields,
|
||||||
|
rundown,
|
||||||
|
commit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add entry to rundown, handles the following cases:
|
||||||
|
* - 1a. add entry in block, after a given entry
|
||||||
|
* - 1b. add entry in block, at the beginning
|
||||||
|
* - 2a. add entry to the rundown, after a given entry
|
||||||
|
* - 2b. add entry to the rundown, at the beginning
|
||||||
|
*/
|
||||||
|
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parentId: EntryId | null): OntimeEntry {
|
||||||
|
if (parentId) {
|
||||||
|
// 1. inserting an entry inside a block
|
||||||
|
const parentBlock = rundown.entries[parentId] as OntimeBlock;
|
||||||
|
if (afterId) {
|
||||||
|
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1;
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
|
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events);
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
} else {
|
||||||
|
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events);
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(parentId) + 1;
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 2. inserting an entry at top level
|
||||||
|
if (afterId) {
|
||||||
|
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
|
||||||
|
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
|
||||||
|
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
|
||||||
|
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
|
||||||
|
} else {
|
||||||
|
rundown.order = insertAtIndex(0, entry.id, rundown.order);
|
||||||
|
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// either way, we insert the entry into the rundown
|
||||||
|
rundown.entries[entry.id] = entry;
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a patch of changes to an existing entry
|
||||||
|
* @returns { entry: OntimeEntry, didInvalidate: boolean } - didInvalidate indicates whether the change warrants a recalculation of the cache
|
||||||
|
*/
|
||||||
|
function edit(rundown: Rundown, patch: PatchWithId): { entry: OntimeEntry; didInvalidate: boolean } {
|
||||||
|
const entry = rundown.entries[patch.id];
|
||||||
|
|
||||||
|
// apply the patch and replace the entry
|
||||||
|
const newEntry = applyPatchToEntry(entry, patch);
|
||||||
|
rundown.entries[entry.id] = newEntry;
|
||||||
|
|
||||||
|
// check whether the data warrants recalculation of cache
|
||||||
|
const didInvalidate = doesInvalidateMetadata(patch);
|
||||||
|
|
||||||
|
return { entry: newEntry, didInvalidate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes an entry from the rundown
|
||||||
|
* - if the entry is an ontime block, we delete it along with its children
|
||||||
|
* - if the entry is inside a block, we delete it and remove the reference from the parent block
|
||||||
|
*/
|
||||||
|
function remove(rundown: Rundown, entry: OntimeEntry) {
|
||||||
|
if (isOntimeBlock(entry)) {
|
||||||
|
// for ontime blocks, we need to iterate through the children and delete them
|
||||||
|
for (let i = 0; i < entry.events.length; i++) {
|
||||||
|
const nestedEntryId = entry.events[i];
|
||||||
|
deleteEntry(nestedEntryId);
|
||||||
|
}
|
||||||
|
} else if (entry.parent) {
|
||||||
|
// at this point, we are handling entries inside a block, so we need to remove the reference
|
||||||
|
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
|
||||||
|
if (parentBlock) {
|
||||||
|
// we call a mutation to the parent event to remove the entry from the events
|
||||||
|
const filteredEvents = deleteById(parentBlock.events, entry.id);
|
||||||
|
edit(rundown, { id: parentBlock.id, events: filteredEvents });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deleteEntry(entry.id);
|
||||||
|
|
||||||
|
function deleteEntry(idToDelete: EntryId) {
|
||||||
|
rundown.order = deleteById(rundown.order, idToDelete);
|
||||||
|
delete rundown.entries[idToDelete];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all entries from the rundown
|
||||||
|
*/
|
||||||
|
function removeAll(rundown: Rundown): Rundown {
|
||||||
|
rundown.order = [];
|
||||||
|
rundown.flatOrder = [];
|
||||||
|
rundown.entries = {};
|
||||||
|
|
||||||
|
return rundown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorders an entry in the rundown
|
||||||
|
* Handle moving across order lists
|
||||||
|
*/
|
||||||
|
function reorder(rundown: Rundown, eventFrom: OntimeEntry, eventTo: OntimeEntry, order: 'before' | 'after' | 'insert') {
|
||||||
|
// handle moving across parents
|
||||||
|
const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null;
|
||||||
|
const toParent = (() => {
|
||||||
|
if (isOntimeBlock(eventTo)) {
|
||||||
|
if (order === 'insert') {
|
||||||
|
return eventTo.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return eventTo.parent ?? null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!isOntimeBlock(eventFrom)) {
|
||||||
|
eventFrom.parent = toParent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events;
|
||||||
|
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events;
|
||||||
|
|
||||||
|
const fromIndex = sourceArray.indexOf(eventFrom.id);
|
||||||
|
const toIndex = (() => {
|
||||||
|
const baseIndex = destinationArray.indexOf(eventTo.id);
|
||||||
|
if (order === 'before') return baseIndex;
|
||||||
|
// only add one if we are moving down
|
||||||
|
if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1);
|
||||||
|
// for insert we add in the end of the array
|
||||||
|
return destinationArray.length;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Remove from source array
|
||||||
|
sourceArray.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
// Insert into destination array
|
||||||
|
destinationArray.splice(toIndex, 0, eventFrom.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies delay from given event ID
|
||||||
|
* Mutates the given rundown
|
||||||
|
*/
|
||||||
|
function applyDelay(rundown: Rundown, delay: OntimeDelay) {
|
||||||
|
const delayIndex = rundownMetadata.flatEntryOrder.indexOf(delay.id);
|
||||||
|
|
||||||
|
// if the delay is empty, or the last element
|
||||||
|
// there is nothing do apply
|
||||||
|
if (delay.duration === 0 || delayIndex === rundown.order.length - 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We iterate through the rundown and apply the delay
|
||||||
|
* The delay values becomes part of the event schedule
|
||||||
|
* The delay is applied as if the rundown was flat
|
||||||
|
*/
|
||||||
|
let delayValue = delay.duration;
|
||||||
|
let lastEntry: OntimeEvent | null = null;
|
||||||
|
let isFirstEvent = true;
|
||||||
|
|
||||||
|
for (let i = delayIndex + 1; i < rundownMetadata.flatEntryOrder.length; i++) {
|
||||||
|
const currentId = rundownMetadata.flatEntryOrder[i];
|
||||||
|
const currentEntry = rundown.entries[currentId];
|
||||||
|
|
||||||
|
// we don't do operation on other event types
|
||||||
|
if (!isOntimeEvent(currentEntry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we need to remove the link in the first event to maintain the gap
|
||||||
|
let shouldUnlink = isFirstEvent;
|
||||||
|
isFirstEvent = false;
|
||||||
|
|
||||||
|
// if the event is not linked, we try and maintain gaps
|
||||||
|
if (lastEntry !== null) {
|
||||||
|
// when applying negative delays, we need to unlink the event
|
||||||
|
// if the previous event was fully consumed by the delay
|
||||||
|
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
||||||
|
shouldUnlink = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentEntry.gap > 0) {
|
||||||
|
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delayValue === 0) {
|
||||||
|
// we can bail from continuing if there are no further delays to apply
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// save the current entry before making mutations on its values
|
||||||
|
lastEntry = { ...currentEntry };
|
||||||
|
|
||||||
|
if (shouldUnlink) {
|
||||||
|
currentEntry.linkStart = false;
|
||||||
|
shouldUnlink = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// event times move up by the delay value
|
||||||
|
// we dont update the delay value since we would need to iterate through the entire dataset
|
||||||
|
// this is handled by the rundownCache.generate function
|
||||||
|
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
|
||||||
|
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
|
||||||
|
currentEntry.revision += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the data between two events
|
||||||
|
* The schedule and metadata are preserved
|
||||||
|
* TODO: this logic is for now duplcate of Ontime-Utils.swapEventData
|
||||||
|
*/
|
||||||
|
function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
|
||||||
|
rundown.entries[eventFrom.id] = {
|
||||||
|
...eventTo,
|
||||||
|
// events keep the ID
|
||||||
|
id: eventFrom.id,
|
||||||
|
// events keep the schedule
|
||||||
|
timeStart: eventFrom.timeStart,
|
||||||
|
timeEnd: eventFrom.timeEnd,
|
||||||
|
duration: eventFrom.duration,
|
||||||
|
linkStart: eventFrom.linkStart,
|
||||||
|
parent: eventFrom.parent,
|
||||||
|
// keep schedule metadata
|
||||||
|
delay: eventFrom.delay,
|
||||||
|
gap: eventFrom.gap,
|
||||||
|
dayOffset: eventFrom.dayOffset,
|
||||||
|
// keep revision number but increment it
|
||||||
|
revision: eventFrom.revision++,
|
||||||
|
};
|
||||||
|
|
||||||
|
rundown.entries[eventTo.id] = {
|
||||||
|
...eventFrom,
|
||||||
|
// events keep the ID
|
||||||
|
id: eventTo.id,
|
||||||
|
// events keep the schedule
|
||||||
|
timeStart: eventTo.timeStart,
|
||||||
|
timeEnd: eventTo.timeEnd,
|
||||||
|
duration: eventTo.duration,
|
||||||
|
linkStart: eventTo.linkStart,
|
||||||
|
parent: eventTo.parent,
|
||||||
|
// keep schedule metadata
|
||||||
|
delay: eventTo.delay,
|
||||||
|
gap: eventTo.gap,
|
||||||
|
dayOffset: eventTo.dayOffset,
|
||||||
|
// keep revision number but increment it
|
||||||
|
revision: eventTo.revision++,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a clone of the given entry into the rundown
|
||||||
|
* Handles cloning children if the entry is a block
|
||||||
|
*/
|
||||||
|
function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
|
||||||
|
if (isOntimeBlock(entry)) {
|
||||||
|
const newBlock = cloneBlock(entry, getUniqueId(rundown));
|
||||||
|
const nestedIds: EntryId[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < entry.events.length; i++) {
|
||||||
|
const nestedEntryId = entry.events[i];
|
||||||
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
|
if (!nestedEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clone the event and assign it to the new block
|
||||||
|
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown));
|
||||||
|
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id;
|
||||||
|
|
||||||
|
nestedIds.push(newNestedEntry.id);
|
||||||
|
// we immediately insert the nested entries into the rundown
|
||||||
|
rundown.entries[newNestedEntry.id] = newNestedEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexes + 1 since we are inserting after the cloned block
|
||||||
|
const atIndex = rundown.order.indexOf(entry.id) + 1;
|
||||||
|
|
||||||
|
newBlock.events = nestedIds;
|
||||||
|
newBlock.title = `${entry.title || 'Untitled'} (copy)`;
|
||||||
|
|
||||||
|
rundown.entries[newBlock.id] = newBlock;
|
||||||
|
rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order);
|
||||||
|
|
||||||
|
return newBlock;
|
||||||
|
} else {
|
||||||
|
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, entry.parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups a list of entries into a block
|
||||||
|
* It ensures that the entries get reassigned parent and the block gets a list of events
|
||||||
|
* The block will be created at the index of the first event in the order, not at the lowest index
|
||||||
|
* Mutates the given rundown
|
||||||
|
*/
|
||||||
|
function group(rundown: Rundown, entryIds: EntryId[]): OntimeBlock {
|
||||||
|
const newBlock = createBlock({ id: getUniqueId(rundown) });
|
||||||
|
|
||||||
|
const nestedEvents: EntryId[] = [];
|
||||||
|
let firstIndex = -1;
|
||||||
|
for (let i = 0; i < entryIds.length; i++) {
|
||||||
|
const entryId = entryIds[i];
|
||||||
|
const entry = rundown.entries[entryId];
|
||||||
|
if (!entry || isOntimeBlock(entry)) {
|
||||||
|
// invalid operation, we skip this entry
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the block will be created at the first selected event position
|
||||||
|
// note that this is not the lowest index
|
||||||
|
if (firstIndex === -1) {
|
||||||
|
firstIndex = rundown.flatOrder.indexOf(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
nestedEvents.push(entryId);
|
||||||
|
entry.parent = newBlock.id;
|
||||||
|
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
|
||||||
|
rundown.order = rundown.order.filter((id) => id !== entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
newBlock.events = nestedEvents;
|
||||||
|
const insertIndex = Math.max(0, firstIndex);
|
||||||
|
// we have filtered the items from the order
|
||||||
|
// we will insert them now, with only the block at top level ...
|
||||||
|
rundown.order = insertAtIndex(insertIndex, newBlock.id, rundown.order);
|
||||||
|
rundown.entries[newBlock.id] = newBlock;
|
||||||
|
|
||||||
|
return newBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a block and moves all its children to the top level order
|
||||||
|
*/
|
||||||
|
function ungroup(rundown: Rundown, block: OntimeBlock) {
|
||||||
|
// get the events from the block and merge them into the order where the block was
|
||||||
|
const nestedEvents = block.events;
|
||||||
|
const blockIndex = rundown.order.indexOf(block.id);
|
||||||
|
rundown.order.splice(blockIndex, 1, ...nestedEvents);
|
||||||
|
|
||||||
|
// delete block from entries and remove its reference from the child events
|
||||||
|
delete rundown.entries[block.id];
|
||||||
|
for (let i = 0; i < nestedEvents.length; i++) {
|
||||||
|
const eventId = nestedEvents[i];
|
||||||
|
const entry = rundown.entries[eventId];
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('Entry not found');
|
||||||
|
}
|
||||||
|
(entry as OntimeEvent | OntimeDelay).parent = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rundownMutation = {
|
||||||
|
add,
|
||||||
|
edit,
|
||||||
|
remove,
|
||||||
|
removeAll,
|
||||||
|
reorder,
|
||||||
|
applyDelay,
|
||||||
|
swap,
|
||||||
|
clone,
|
||||||
|
group,
|
||||||
|
ungroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose function to add an initial rundown to the system
|
||||||
|
*/
|
||||||
|
export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Readonly<CustomFields>) {
|
||||||
|
const rundown = structuredClone(initialRundown);
|
||||||
|
const customFields = structuredClone(initialCustomFields);
|
||||||
|
const processedData = processRundown(rundown, customFields);
|
||||||
|
|
||||||
|
// update the cache values
|
||||||
|
cachedRundown.id = rundown.id;
|
||||||
|
cachedRundown.title = rundown.title;
|
||||||
|
projectCustomFields = customFields;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||||
|
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||||
|
cachedRundown.entries = entries;
|
||||||
|
cachedRundown.order = order;
|
||||||
|
cachedRundown.flatOrder = metadata.flatEntryOrder; // TODO: remove in favour of the metadata flatEntryOrder
|
||||||
|
cachedRundown.revision = rundown.revision;
|
||||||
|
rundownMetadata = metadata;
|
||||||
|
|
||||||
|
// defer writing to the database
|
||||||
|
setImmediate(async () => {
|
||||||
|
await getDataProvider().setRundown(cachedRundown.id, cachedRundown);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rundown, rundownMetadata, customFields, revision: rundown.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rundownCache = {
|
||||||
|
init,
|
||||||
|
get: () => {
|
||||||
|
return {
|
||||||
|
rundown: cachedRundown,
|
||||||
|
metadata: rundownMetadata,
|
||||||
|
customFields: projectCustomFields,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility updates cache after a mutation
|
||||||
|
* Handles calculating the rundown metadata
|
||||||
|
* @private should not be called outside of `rundown.dao.ts`, exported for testing
|
||||||
|
*/
|
||||||
|
export function processRundown(
|
||||||
|
initialRundown: Readonly<Rundown>,
|
||||||
|
customFields: Readonly<CustomFields>,
|
||||||
|
): ProcessedRundownMetadata {
|
||||||
|
const { process, getMetadata } = makeRundownMetadata(customFields, customFieldChangelog);
|
||||||
|
|
||||||
|
for (let i = 0; i < initialRundown.order.length; i++) {
|
||||||
|
// we assign a reference to the current entry, this will be mutated in place
|
||||||
|
const currentEntryId = initialRundown.order[i];
|
||||||
|
const currentEntry = initialRundown.entries[currentEntryId];
|
||||||
|
if (!currentEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { processedEntry } = process(currentEntry, null);
|
||||||
|
|
||||||
|
// if the event is a block, we process the nested entries
|
||||||
|
// the code here is a copy of the processing of top level events
|
||||||
|
if (isOntimeBlock(processedEntry)) {
|
||||||
|
let totalBlockDuration = 0;
|
||||||
|
let blockStartTime = null;
|
||||||
|
let blockEndTime = null;
|
||||||
|
let isFirstLinked = false;
|
||||||
|
const blockEvents: EntryId[] = [];
|
||||||
|
|
||||||
|
// check if the block contains nested entries
|
||||||
|
for (let j = 0; j < processedEntry.events.length; j++) {
|
||||||
|
const nestedEntryId = processedEntry.events[j];
|
||||||
|
const nestedEntry = initialRundown.entries[nestedEntryId];
|
||||||
|
|
||||||
|
if (!nestedEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockEvents.push(nestedEntry.id);
|
||||||
|
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
|
||||||
|
nestedEntry,
|
||||||
|
processedEntry.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// we dont extract metadata of skipped events,
|
||||||
|
// if this is not a playable event there is nothing else to do
|
||||||
|
if (!isOntimeEvent(processedNestedEntry) || !isPlayableEvent(processedNestedEntry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// first start is always the first event
|
||||||
|
if (blockStartTime === null) {
|
||||||
|
blockStartTime = processedNestedEntry.timeStart;
|
||||||
|
isFirstLinked = Boolean(processedNestedEntry.linkStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastEntry is the event with the latest end time
|
||||||
|
blockEndTime = processedNestedData.lastEnd;
|
||||||
|
totalBlockDuration += processedNestedEntry.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// update block metadata
|
||||||
|
processedEntry.duration = totalBlockDuration;
|
||||||
|
processedEntry.startTime = blockStartTime;
|
||||||
|
processedEntry.endTime = blockEndTime;
|
||||||
|
processedEntry.isFirstLinked = isFirstLinked;
|
||||||
|
processedEntry.events = blockEvents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getMetadata();
|
||||||
|
}
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
import { ErrorResponse, Rundown } from 'ontime-types';
|
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
||||||
import { getErrorMessage } from 'ontime-utils';
|
import { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
|
||||||
import { reorderEntry } from '../../services/rundown-service/RundownService.js';
|
import { getCurrentRundown } from './rundown.dao.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deletesEventById,
|
addEntry,
|
||||||
rundownAddToBlock,
|
applyDelay,
|
||||||
rundownApplyDelay,
|
batchEditEntries,
|
||||||
rundownBatchPut,
|
cloneEntry,
|
||||||
rundownCloneEntry,
|
deleteAllEntries,
|
||||||
rundownDelete,
|
deleteEntries,
|
||||||
rundownUngroupEntries,
|
editEntry,
|
||||||
rundownGetAll,
|
groupEntries,
|
||||||
rundownGetById,
|
reorderEntry,
|
||||||
rundownGetCurrent,
|
swapEvents,
|
||||||
rundownPost,
|
ungroupEntries,
|
||||||
rundownPut,
|
} from './rundown.service.js';
|
||||||
rundownSwap,
|
|
||||||
} from './rundown.controller.js';
|
|
||||||
import {
|
import {
|
||||||
paramsMustHaveEntryId,
|
paramsMustHaveEntryId,
|
||||||
rundownArrayOfIds,
|
rundownArrayOfIds,
|
||||||
@@ -33,14 +30,53 @@ import {
|
|||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
|
|
||||||
router.get('/', rundownGetAll);
|
/**
|
||||||
router.get('/current', rundownGetCurrent);
|
* Returns all rundowns in the project
|
||||||
router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend
|
*/
|
||||||
|
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
|
||||||
router.post('/', rundownPostValidator, rundownPost);
|
// TODO: we currently make a project with only the current rundown
|
||||||
|
res.json([{ id: rundown.id, title: rundown.title, numEntries: rundown.order.length, revision: rundown.revision }]);
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/', rundownPutValidator, rundownPut);
|
/**
|
||||||
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
|
* Returns the current rundown
|
||||||
|
*/
|
||||||
|
router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||||
|
const rundown = getCurrentRundown();
|
||||||
|
res.json(rundown);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', rundownPostValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newEvent = await addEntry(req.body);
|
||||||
|
res.status(201).send(newEvent);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/', rundownPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const event = await editEntry(req.body);
|
||||||
|
res.status(200).send(event);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
try {
|
try {
|
||||||
@@ -52,11 +88,81 @@ router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Resp
|
|||||||
res.status(400).send({ message });
|
res.status(400).send({ message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
router.patch('/swap', rundownSwapValidator, rundownSwap);
|
|
||||||
router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay);
|
|
||||||
router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry);
|
|
||||||
router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries);
|
|
||||||
router.post('/group', rundownArrayOfIds, rundownAddToBlock);
|
|
||||||
|
|
||||||
router.delete('/', rundownArrayOfIds, deletesEventById);
|
router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
router.delete('/all', rundownDelete);
|
try {
|
||||||
|
const rundown = await swapEvents(req.body.from, req.body.to);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch(
|
||||||
|
'/applydelay/:entryId',
|
||||||
|
paramsMustHaveEntryId,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newRundown = await applyDelay(req.params.entryId);
|
||||||
|
res.status(200).send(newRundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/clone/:entryId', paramsMustHaveEntryId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newRundown = await cloneEntry(req.params.entryId);
|
||||||
|
res.status(200).send(newRundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newRundown = await groupEntries(req.body.ids);
|
||||||
|
res.status(200).send(newRundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/ungroup/:entryId',
|
||||||
|
paramsMustHaveEntryId,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const newRundown = await ungroupEntries(req.params.entryId);
|
||||||
|
res.status(200).send(newRundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
await deleteEntries(req.body.ids);
|
||||||
|
res.status(204).send({ message: 'Events deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/all', async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await deleteAllEntries();
|
||||||
|
res.status(204).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import {
|
||||||
|
CustomFields,
|
||||||
|
EntryId,
|
||||||
|
EventPostPayload,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeEvent,
|
||||||
|
OntimeEntry,
|
||||||
|
PatchWithId,
|
||||||
|
Rundown,
|
||||||
|
} from 'ontime-types';
|
||||||
|
|
||||||
|
import { getPreviousId } from '../../services/rundown-service/rundownUtils.js';
|
||||||
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
|
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||||
|
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||||
|
|
||||||
|
import { createTransaction, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||||
|
import { RundownMetadata } from './rundown.types.js';
|
||||||
|
import { generateEvent, hasChanges } from './rundown.utils.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* creates a new entry with given data
|
||||||
|
*/
|
||||||
|
export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
// we allow the user to provide an ID, but make sure it is unique
|
||||||
|
if (eventData?.id && Object.hasOwn(rundown.entries, eventData.id)) {
|
||||||
|
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||||
|
let parent: EntryId | null = null;
|
||||||
|
if ('parent' in eventData && eventData.parent != null) {
|
||||||
|
const maybeParent = rundown.entries[eventData.parent];
|
||||||
|
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
||||||
|
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
||||||
|
}
|
||||||
|
parent = eventData.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalise the position of the event in the rundown order
|
||||||
|
const afterId = getPreviousId(rundown, eventData?.after, eventData?.before);
|
||||||
|
|
||||||
|
// generate a fully formed entry from the patch
|
||||||
|
const newEntry = generateEvent(rundown, eventData, afterId);
|
||||||
|
|
||||||
|
// make mutations to rundown
|
||||||
|
rundownMutation.add(rundown, newEntry, afterId, parent);
|
||||||
|
const { rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a patch to an entry in the rundown
|
||||||
|
*/
|
||||||
|
export async function editEntry(patch: PatchWithId): Promise<OntimeEntry> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
const currentEntry = rundown.entries[patch.id];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We validate the patch before applying it
|
||||||
|
* - disallow edit an entry that does not exist
|
||||||
|
* - disallow setting the cue to empty string
|
||||||
|
* - disallow change the type of an entry
|
||||||
|
*/
|
||||||
|
|
||||||
|
// could the entry have been deleted?
|
||||||
|
if (!currentEntry) {
|
||||||
|
throw new Error('Entry not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// we dont allow the user to change the cue to empty string
|
||||||
|
if (isOntimeEvent(patch) && patch?.cue === '') {
|
||||||
|
throw new Error('Cue value invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
// we cannot allow patching to a different type
|
||||||
|
if (patch?.type && currentEntry.type !== patch.type) {
|
||||||
|
throw new Error('Invalid event type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if nothing changed, nothing to do
|
||||||
|
if (!hasChanges(currentEntry, patch)) {
|
||||||
|
return currentEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entry, didInvalidate } = rundownMutation.edit(rundown, patch);
|
||||||
|
const { rundownMetadata, revision } = commit(didInvalidate);
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: [entry.id], external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a patch to several entries in the rundown
|
||||||
|
*/
|
||||||
|
export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntry>): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We can do some validation globally, but mostly we will validate each entry individually
|
||||||
|
* - disallow setting the cue to empty string
|
||||||
|
*/
|
||||||
|
if ('cue' in patch && patch.cue === '') {
|
||||||
|
throw new Error('Cue value invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
let batchDidInvalidate = false;
|
||||||
|
const changedIds: EntryId[] = [];
|
||||||
|
const patchedEntries: OntimeEntry[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const currentId = ids[i];
|
||||||
|
const currentEntry = rundown.entries[currentId];
|
||||||
|
/**
|
||||||
|
* Most of the validation needs to be done in regard to the change
|
||||||
|
* - cannot edit an entry that does not exist
|
||||||
|
* - disallow change the type of an entry
|
||||||
|
* - disallow change the ID of an entry
|
||||||
|
*/
|
||||||
|
// could the entry have been deleted?
|
||||||
|
if (!currentEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we cannot allow patching to a different type
|
||||||
|
if (patch?.type && currentEntry.type !== patch.type) {
|
||||||
|
throw new Error('Invalid event type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// if nothing changed, nothing to do
|
||||||
|
if (!hasChanges(currentEntry, patch)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
|
||||||
|
|
||||||
|
changedIds.push(currentId);
|
||||||
|
patchedEntries.push(entry);
|
||||||
|
|
||||||
|
if (didInvalidate) {
|
||||||
|
batchDidInvalidate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit(batchDidInvalidate);
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: changedIds, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a known entry from the current rundown
|
||||||
|
*/
|
||||||
|
export async function deleteEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
for (let i = 0; i < entryIds.length; i++) {
|
||||||
|
const entry = rundown.entries[entryIds[i]];
|
||||||
|
if (!entry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rundownMutation.remove(rundown, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: entryIds, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes all entries from the current rundown
|
||||||
|
*/
|
||||||
|
export async function deleteAllEntries(): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
rundownMutation.removeAll(rundown);
|
||||||
|
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an event to a new position in the rundown
|
||||||
|
* Handles moving across root orders (a block order and top level order)
|
||||||
|
* @throws if entryId or destinationId not found
|
||||||
|
*/
|
||||||
|
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
// check that both entries exist
|
||||||
|
const eventFrom = rundown.entries[entryId];
|
||||||
|
const eventTo = rundown.entries[destinationId];
|
||||||
|
|
||||||
|
if (!eventFrom || !eventTo) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
rundownMutation.reorder(rundown, eventFrom, eventTo, order);
|
||||||
|
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a delay into the rundown effectively changing the schedule
|
||||||
|
* The applied delay is deleted
|
||||||
|
*/
|
||||||
|
export async function applyDelay(delayId: EntryId): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
// check that delay exists
|
||||||
|
const delay = rundown.entries[delayId];
|
||||||
|
if (!delay || !isOntimeDelay(delay)) {
|
||||||
|
throw new Error('Given delay ID not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply the delay and delete the it
|
||||||
|
rundownMutation.applyDelay(rundown, delay);
|
||||||
|
rundownMutation.remove(rundown, delay);
|
||||||
|
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swaps the data between two events in the rundown
|
||||||
|
*/
|
||||||
|
export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
const eventFrom = rundown.entries[fromId];
|
||||||
|
const eventTo = rundown.entries[toId];
|
||||||
|
|
||||||
|
// check that both entries exist
|
||||||
|
if (!eventFrom || !eventTo) {
|
||||||
|
throw new Error('Event not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// we can only swap events
|
||||||
|
if (!isOntimeEvent(eventFrom) || !isOntimeEvent(eventTo)) {
|
||||||
|
throw new Error('Both entries must be events');
|
||||||
|
}
|
||||||
|
|
||||||
|
rundownMutation.swap(rundown, eventFrom, eventTo);
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones an entry, ensuring that all dependencies are preserved
|
||||||
|
* @throws if the entry to clone does not exist
|
||||||
|
*/
|
||||||
|
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
const originalEntry = rundown.entries[entryId];
|
||||||
|
|
||||||
|
if (!originalEntry) {
|
||||||
|
throw new Error('Did not find event to clone');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEntry = rundownMutation.clone(rundown, originalEntry);
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer and external services of change
|
||||||
|
if (isOntimeBlock(newEntry)) {
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: newEntry.events, external: true });
|
||||||
|
} else if (isOntimeEvent(newEntry)) {
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: [newEntry.id], external: true });
|
||||||
|
} else if (isOntimeDelay(newEntry)) {
|
||||||
|
notifyChanges(rundownMetadata, revision, { external: true });
|
||||||
|
}
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups a list of entries into a new block
|
||||||
|
*/
|
||||||
|
export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
rundownMutation.group(rundown, entryIds);
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// we dont need to notify the timer since the grouping does not affect the runtime
|
||||||
|
notifyChanges(rundownMetadata, revision, { external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a block and moves all its children to the top level
|
||||||
|
*/
|
||||||
|
export async function ungroupEntries(blockId: EntryId): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction();
|
||||||
|
|
||||||
|
const block = rundown.entries[blockId];
|
||||||
|
if (!block || !isOntimeBlock(block)) {
|
||||||
|
throw new Error(`Block with ID ${blockId} not found or is not a block`);
|
||||||
|
}
|
||||||
|
|
||||||
|
rundownMutation.ungroup(rundown, block);
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// we dont need to notify the timer since the grouping does not affect the runtime
|
||||||
|
notifyChanges(rundownMetadata, revision, { external: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces update in the store
|
||||||
|
* Called when we make changes to the rundown object
|
||||||
|
*
|
||||||
|
* @private - exported for testing
|
||||||
|
*/
|
||||||
|
export function updateRuntimeOnChange(rundownMetadata: RundownMetadata) {
|
||||||
|
// we only declare the amount of playable events
|
||||||
|
const numEvents = rundownMetadata.timedEventOrder.length;
|
||||||
|
|
||||||
|
// schedule an update for the end of the event loop
|
||||||
|
updateRundownData({
|
||||||
|
numEvents,
|
||||||
|
...rundownMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyChangesOptions = {
|
||||||
|
timer?: boolean | string[]; // whether to notify the timer, could be a yes / no or an array of affected IDs
|
||||||
|
external?: boolean; // whether to notify external services
|
||||||
|
reload?: boolean; // major change, clients should consider refetching everything
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify services of changes in the rundown
|
||||||
|
*
|
||||||
|
* @private - exported for testing
|
||||||
|
*/
|
||||||
|
export function notifyChanges(rundownMetadata: RundownMetadata, revision: number, options: NotifyChangesOptions) {
|
||||||
|
// notify timer service of changed events
|
||||||
|
if (options.timer) {
|
||||||
|
// all events were deleted
|
||||||
|
if (rundownMetadata.playableEventOrder.length === 0) {
|
||||||
|
runtimeService.stop();
|
||||||
|
} else {
|
||||||
|
/**
|
||||||
|
* Timer can be
|
||||||
|
* - true: all events changed
|
||||||
|
* - an array of changed IDs
|
||||||
|
* - undefined: filtered above, no notification intended
|
||||||
|
*/
|
||||||
|
// timer can be true or an array of changed IDs
|
||||||
|
const affected = Array.isArray(options.timer) ? options.timer : undefined;
|
||||||
|
runtimeService.notifyOfChangedEvents(affected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify external services of changes
|
||||||
|
if (options.external) {
|
||||||
|
const payload = {
|
||||||
|
target: 'RUNDOWN',
|
||||||
|
reload: options.reload,
|
||||||
|
revision,
|
||||||
|
};
|
||||||
|
sendRefetch(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a new rundown in the cache
|
||||||
|
* and marks it as the currently loaded one
|
||||||
|
*/
|
||||||
|
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
||||||
|
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||||
|
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// notify timer of change
|
||||||
|
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true });
|
||||||
|
}
|
||||||
@@ -1,9 +1,57 @@
|
|||||||
import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
import {
|
||||||
import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
EntryId,
|
||||||
|
isOntimeBlock,
|
||||||
|
isOntimeDelay,
|
||||||
|
isOntimeEvent,
|
||||||
|
OntimeBaseEvent,
|
||||||
|
OntimeBlock,
|
||||||
|
OntimeDelay,
|
||||||
|
OntimeEntry,
|
||||||
|
OntimeEvent,
|
||||||
|
Rundown,
|
||||||
|
SupportedEntry,
|
||||||
|
TimeStrategy,
|
||||||
|
} from 'ontime-types';
|
||||||
|
import { generateId, getCueCandidate, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
|
||||||
|
|
||||||
import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js';
|
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||||
import { makeString } from '../../utils/parserUtils.js';
|
import { makeString } from '../../utils/parserUtils.js';
|
||||||
|
|
||||||
|
type CompleteEntry<T> =
|
||||||
|
T extends Partial<OntimeEvent>
|
||||||
|
? OntimeEvent
|
||||||
|
: T extends Partial<OntimeDelay>
|
||||||
|
? OntimeDelay
|
||||||
|
: T extends Partial<OntimeBlock>
|
||||||
|
? OntimeBlock
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a fully formed RundownEntry of the patch type
|
||||||
|
*/
|
||||||
|
export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
|
||||||
|
rundown: Rundown,
|
||||||
|
eventData: T,
|
||||||
|
afterId: EntryId | null,
|
||||||
|
): CompleteEntry<T> {
|
||||||
|
if (isOntimeEvent(eventData)) {
|
||||||
|
return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = eventData.id || getUniqueId(rundown);
|
||||||
|
|
||||||
|
if (isOntimeDelay(eventData)) {
|
||||||
|
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(v4): allow user to provide a larger patch of the block entry
|
||||||
|
if (isOntimeBlock(eventData)) {
|
||||||
|
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid event type');
|
||||||
|
}
|
||||||
|
|
||||||
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
||||||
if (Object.keys(patchEvent).length === 0) {
|
if (Object.keys(patchEvent).length === 0) {
|
||||||
return originalEvent;
|
return originalEvent;
|
||||||
@@ -46,6 +94,26 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function for patching an existing event with new data
|
||||||
|
* Increments the revision of the event when applying the patch
|
||||||
|
*/
|
||||||
|
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
|
||||||
|
if (isOntimeEvent(eventFromRundown)) {
|
||||||
|
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
|
||||||
|
newEvent.revision++;
|
||||||
|
return newEvent as T;
|
||||||
|
}
|
||||||
|
if (isOntimeBlock(eventFromRundown)) {
|
||||||
|
const newBlock: OntimeBlock = { ...eventFromRundown, ...patch };
|
||||||
|
newBlock.revision++;
|
||||||
|
return newBlock as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// only delay is left
|
||||||
|
return { ...eventFromRundown, ...patch } as T;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Enforces formatting for events
|
* @description Enforces formatting for events
|
||||||
* @param {object} eventArgs - attributes of event
|
* @param {object} eventArgs - attributes of event
|
||||||
@@ -110,3 +178,125 @@ function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy):
|
|||||||
}
|
}
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a given ID is exists in the current rundown
|
||||||
|
*/
|
||||||
|
export function hasId(rundown: Rundown, id: EntryId): boolean {
|
||||||
|
return Object.hasOwn(rundown.entries, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an ID guaranteed to be unique
|
||||||
|
*/
|
||||||
|
export function getUniqueId(rundown: Rundown): EntryId {
|
||||||
|
let id: EntryId;
|
||||||
|
do {
|
||||||
|
id = generateId();
|
||||||
|
} while (rundown.entries[id]);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
|
enum RegenerateWhitelist {
|
||||||
|
'id', // adding it for completeness, users cannot change ID
|
||||||
|
'type', // adding it for completeness, users cannot change ID
|
||||||
|
'cue',
|
||||||
|
'title',
|
||||||
|
'note',
|
||||||
|
'endAction',
|
||||||
|
'timerType',
|
||||||
|
'countToEnd',
|
||||||
|
'isPublic',
|
||||||
|
'colour',
|
||||||
|
'timeWarning',
|
||||||
|
'timeDanger',
|
||||||
|
'custom',
|
||||||
|
'triggers',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* given a patch, returns whether it invalidates the rundown metadata
|
||||||
|
*/
|
||||||
|
export function doesInvalidateMetadata(patch: Partial<OntimeEntry>): boolean {
|
||||||
|
return Object.keys(patch).some(willCauseRegeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* given a key, returns whether it is whitelisted
|
||||||
|
*/
|
||||||
|
export function willCauseRegeneration(key: string): boolean {
|
||||||
|
return !(key in RegenerateWhitelist);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given an event and a patch to that event checks whether there are actual changes to the dataset
|
||||||
|
* @param existingEvent
|
||||||
|
* @param newEvent
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
||||||
|
return Object.keys(newEvent).some(
|
||||||
|
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the first instance of string from an array of strings
|
||||||
|
* Used for cases when we want to delete an ID from an array
|
||||||
|
*
|
||||||
|
* We keep this just for backend because the use of `toSpliced` does not have enough browser support
|
||||||
|
*/
|
||||||
|
export function deleteById(array: EntryId[], deleteId: EntryId): EntryId[] {
|
||||||
|
const deleteIndex = array.findIndex((id) => id === deleteId);
|
||||||
|
if (deleteIndex === -1) {
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
return array.toSpliced(deleteIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers business logic for how to clone an OntimeEvent
|
||||||
|
*/
|
||||||
|
export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent {
|
||||||
|
const newEntry = structuredClone(entry);
|
||||||
|
newEntry.id = newId;
|
||||||
|
newEntry.revision = 0;
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers business logic for how to clone an OntimeDelay
|
||||||
|
*/
|
||||||
|
export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
|
||||||
|
const newEntry = structuredClone(entry);
|
||||||
|
newEntry.id = newId;
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gathers business logic for how to clone an OntimeBlock
|
||||||
|
*/
|
||||||
|
export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
|
||||||
|
const newEntry = structuredClone(entry);
|
||||||
|
newEntry.id = newId;
|
||||||
|
|
||||||
|
// in blocks, we need to remove the events references
|
||||||
|
newEntry.events = [];
|
||||||
|
newEntry.revision = 0;
|
||||||
|
return newEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives an entry and chooses the correct cloning strategy
|
||||||
|
*/
|
||||||
|
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
|
||||||
|
if (isOntimeEvent(entry)) {
|
||||||
|
return cloneEvent(entry, newId) as T;
|
||||||
|
} else if (isOntimeDelay(entry)) {
|
||||||
|
return cloneDelay(entry, newId) as T;
|
||||||
|
} else if (entry.type === 'block') {
|
||||||
|
return cloneBlock(entry as OntimeBlock, newId) as T;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export const rundownPutValidator = [
|
|||||||
|
|
||||||
export const rundownBatchPutValidator = [
|
export const rundownBatchPutValidator = [
|
||||||
body('data').isObject().exists(),
|
body('data').isObject().exists(),
|
||||||
body('ids').isArray().exists(),
|
body('ids').isArray().notEmpty(),
|
||||||
|
body('ids.*').isString(),
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MessageState, OffsetMode, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
import { MessageState, OffsetMode, OntimeEvent, PatchWithId, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
|
|
||||||
import { DeepPartial } from 'ts-essentials';
|
import { DeepPartial } from 'ts-essentials';
|
||||||
@@ -11,13 +11,14 @@ import { runtimeService } from '../services/runtime-service/RuntimeService.js';
|
|||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import * as assert from '../utils/assert.js';
|
import * as assert from '../utils/assert.js';
|
||||||
import { isEmptyObject } from '../utils/parserUtils.js';
|
import { isEmptyObject } from '../utils/parserUtils.js';
|
||||||
import { parseProperty, updateEvent } from './integration.utils.js';
|
import { parseProperty } from './integration.utils.js';
|
||||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||||
import { throttle } from '../utils/throttle.js';
|
import { throttle } from '../utils/throttle.js';
|
||||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCache.utils.js';
|
|
||||||
import { coerceEnum } from '../utils/coerceType.js';
|
import { coerceEnum } from '../utils/coerceType.js';
|
||||||
|
import { editEntry } from '../api-data/rundown/rundown.service.js';
|
||||||
|
import { willCauseRegeneration } from '../api-data/rundown/rundown.utils.js';
|
||||||
|
|
||||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
const throttledEditEvent = throttle(editEntry, 20);
|
||||||
let lastRequest: Date | null = null;
|
let lastRequest: Date | null = null;
|
||||||
|
|
||||||
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
||||||
@@ -56,7 +57,7 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = payload[id as keyof typeof payload];
|
const data = payload[id as keyof typeof payload];
|
||||||
const patchEvent: Partial<OntimeEvent> & { id: string } = { id };
|
const patchEvent: PatchWithId<OntimeEvent> = { id };
|
||||||
|
|
||||||
let shouldThrottle = false;
|
let shouldThrottle = false;
|
||||||
|
|
||||||
@@ -76,11 +77,13 @@ const actionHandlers: Record<string, ActionHandler> = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (shouldThrottle) {
|
if (shouldThrottle) {
|
||||||
if (throttledUpdateEvent(patchEvent)) {
|
if (throttledEditEvent(patchEvent)) {
|
||||||
return { payload: 'throttled' };
|
return { payload: 'throttled' };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updateEvent(patchEvent);
|
editEntry(patchEvent).catch((_error) => {
|
||||||
|
/** No error handling */
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { payload: 'success' };
|
return { payload: 'success' };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
import { EndAction, TimerType, isKeyOfType } from 'ontime-types';
|
||||||
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
|
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
|
||||||
|
|
||||||
import { editEvent } from '../services/rundown-service/RundownService.js';
|
|
||||||
import { getEntryWithId } from '../services/rundown-service/rundownUtils.js';
|
|
||||||
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
||||||
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
|
||||||
@@ -58,19 +56,3 @@ export function parseProperty(property: string, value: unknown) {
|
|||||||
const parserFn = propertyConversion[property];
|
const parserFn = propertyConversion[property];
|
||||||
return { [property]: parserFn(value) };
|
return { [property]: parserFn(value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates a property of the event with the given id
|
|
||||||
* @param {Partial<OntimeEvent>} patchEvent
|
|
||||||
*/
|
|
||||||
export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
|
|
||||||
const event = getEntryWithId(patchEvent?.id ?? '');
|
|
||||||
if (!event) {
|
|
||||||
throw new Error(`Event with ID ${patchEvent?.id} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOntimeEvent(event)) {
|
|
||||||
throw new Error('Can only update events');
|
|
||||||
}
|
|
||||||
editEvent(patchEvent);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { restoreService } from './services/RestoreService.js';
|
|||||||
import * as messageService from './services/message-service/MessageService.js';
|
import * as messageService from './services/message-service/MessageService.js';
|
||||||
import { populateDemo } from './setup/loadDemo.js';
|
import { populateDemo } from './setup/loadDemo.js';
|
||||||
import { getState } from './stores/runtimeState.js';
|
import { getState } from './stores/runtimeState.js';
|
||||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
import { initRundown } from './api-data/rundown/rundown.service.js';
|
||||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||||
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||||
import { oscServer } from './adapters/OscAdapter.js';
|
import { oscServer } from './adapters/OscAdapter.js';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DatabaseModel, Settings, URLPreset } from 'ontime-types';
|
import { DatabaseModel, Settings, URLPreset } from 'ontime-types';
|
||||||
|
|
||||||
import { demoDb } from '../../../models/demoProject.js';
|
import { demoDb } from '../../../models/demoProject.js';
|
||||||
import { makeOntimeEvent, makeRundown } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
import { safeMerge } from '../DataProvider.utils.js';
|
import { safeMerge } from '../DataProvider.utils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
|
|
||||||
import { loadRoll } from '../rollUtils.js';
|
import { loadRoll } from '../rollUtils.js';
|
||||||
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js';
|
import { prepareTimedEvents, makeOntimeEvent } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
describe('loadRoll()', () => {
|
describe('loadRoll()', () => {
|
||||||
const eventlist = [
|
const eventlist = [
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import { demoDb } from '../../models/demoProject.js';
|
|||||||
import { config } from '../../setup/config.js';
|
import { config } from '../../setup/config.js';
|
||||||
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
|
||||||
|
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||||
|
|
||||||
import { initRundown } from '../rundown-service/RundownService.js';
|
|
||||||
import {
|
import {
|
||||||
getLastLoadedProject,
|
getLastLoadedProject,
|
||||||
isLastLoadedProject,
|
isLastLoadedProject,
|
||||||
|
|||||||
@@ -1,295 +1,10 @@
|
|||||||
import {
|
import { CustomFields, Rundown } from 'ontime-types';
|
||||||
CustomFields,
|
|
||||||
OntimeBlock,
|
|
||||||
OntimeDelay,
|
|
||||||
OntimeEvent,
|
|
||||||
OntimeEntry,
|
|
||||||
isOntimeBlock,
|
|
||||||
isOntimeDelay,
|
|
||||||
isOntimeEvent,
|
|
||||||
PatchWithId,
|
|
||||||
EventPostPayload,
|
|
||||||
Rundown,
|
|
||||||
EntryId,
|
|
||||||
} from 'ontime-types';
|
|
||||||
import { getCueCandidate } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { delay as delayDef } from '../../models/eventsDefinition.js';
|
|
||||||
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
|
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
|
||||||
import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js';
|
|
||||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
import * as cache from './rundownCache.js';
|
||||||
import { getPreviousId } from './rundownUtils.js';
|
|
||||||
|
|
||||||
type CompleteEntry<T> =
|
|
||||||
T extends Partial<OntimeEvent>
|
|
||||||
? OntimeEvent
|
|
||||||
: T extends Partial<OntimeDelay>
|
|
||||||
? OntimeDelay
|
|
||||||
: T extends Partial<OntimeBlock>
|
|
||||||
? 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> {
|
|
||||||
if (isOntimeEvent(eventData)) {
|
|
||||||
const currentRundown = cache.getCurrentRundown();
|
|
||||||
return createEvent(
|
|
||||||
eventData,
|
|
||||||
getCueCandidate(currentRundown.entries, currentRundown.order, afterId),
|
|
||||||
) as CompleteEntry<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = eventData.id || cache.getUniqueId();
|
|
||||||
|
|
||||||
if (isOntimeDelay(eventData)) {
|
|
||||||
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(v4): allow user to provide a larger patch of the block entry
|
|
||||||
if (isOntimeBlock(eventData)) {
|
|
||||||
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Invalid event type');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* creates a new event with given data
|
|
||||||
*/
|
|
||||||
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
|
|
||||||
// 1. we allow the user to provide an ID, but make sure it is unique
|
|
||||||
if (eventData?.id && cache.hasId(eventData.id)) {
|
|
||||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. if the user provides a parent (inside a group), we make sure it exists and it is a group
|
|
||||||
let parent: EntryId | null = null;
|
|
||||||
if ('parent' in eventData && eventData.parent != null) {
|
|
||||||
const maybeParent = cache.getCurrentRundown().entries[eventData.parent];
|
|
||||||
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
|
||||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
|
||||||
}
|
|
||||||
parent = eventData.parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. if the user provides an after or before ID, we make sure it exists
|
|
||||||
if (eventData?.after !== undefined) {
|
|
||||||
if (!cache.hasId(eventData.after)) {
|
|
||||||
throw new Error(`Event with ID ${eventData.after} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (eventData?.before !== undefined) {
|
|
||||||
if (!cache.hasId(eventData.before)) {
|
|
||||||
throw new Error(`Event with ID ${eventData.before} not found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const afterId = getPreviousId(eventData?.after, eventData?.before);
|
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
|
||||||
const sanitisedEntry = generateEvent(eventData, afterId);
|
|
||||||
|
|
||||||
// modify rundown
|
|
||||||
const scopedMutation = cache.mutateCache(cache.add);
|
|
||||||
const { newEvent } = await scopedMutation({ afterId, parent, entry: sanitisedEntry });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: [sanitisedEntry.id], external: true });
|
|
||||||
|
|
||||||
// we know this mutation returns an OntimeEntry
|
|
||||||
return newEvent as OntimeEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* deletes event by its ID
|
|
||||||
*/
|
|
||||||
export async function deleteEvent(eventIds: EntryId[]) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.remove);
|
|
||||||
const { didMutate, changeList } = await scopedMutation({ eventIds });
|
|
||||||
|
|
||||||
if (!didMutate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: changeList, external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* deletes all entries in database
|
|
||||||
*/
|
|
||||||
export async function deleteAllEntries() {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.removeAll);
|
|
||||||
await scopedMutation({});
|
|
||||||
|
|
||||||
// notify event loader that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: true, external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply patch to an element in rundown
|
|
||||||
* @param patch
|
|
||||||
*/
|
|
||||||
export async function editEvent(patch: PatchWithId) {
|
|
||||||
if (isOntimeEvent(patch) && patch?.cue === '') {
|
|
||||||
throw new Error('Cue value invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopedMutation = cache.mutateCache(cache.edit);
|
|
||||||
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
|
|
||||||
|
|
||||||
// short circuit if nothing changed
|
|
||||||
if (!didMutate) {
|
|
||||||
return newEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: [patch.id], external: true });
|
|
||||||
|
|
||||||
return newEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a patch to several elements in a rundown
|
|
||||||
* @param ids
|
|
||||||
* @param data
|
|
||||||
*/
|
|
||||||
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.batchEdit);
|
|
||||||
await scopedMutation({ patch: data, eventIds: ids });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: ids, external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* reorders a given entry
|
|
||||||
*/
|
|
||||||
export async function reorderEntry(
|
|
||||||
entryId: EntryId,
|
|
||||||
destinationId: EntryId,
|
|
||||||
order: 'before' | 'after' | 'insert',
|
|
||||||
): Promise<Rundown> {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.reorder);
|
|
||||||
const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: changeList, external: true });
|
|
||||||
|
|
||||||
return newRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a delay into the rundown effectively changing the schedule
|
|
||||||
* The applied delay is deleted
|
|
||||||
* @param delayId
|
|
||||||
*/
|
|
||||||
export async function applyDelay(delayId: EntryId) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.applyDelay);
|
|
||||||
await scopedMutation({ delayId });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: true, external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clones an entry, ensuring that all dependencies are preserved
|
|
||||||
*/
|
|
||||||
export async function cloneEntry(entryId: EntryId) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.clone);
|
|
||||||
const { newRundown, newEvent } = await scopedMutation({ entryId });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
if (isOntimeBlock(newEvent)) {
|
|
||||||
notifyChanges({ timer: newEvent.events, external: true });
|
|
||||||
} else if (isOntimeEvent(newEvent)) {
|
|
||||||
notifyChanges({ timer: [newEvent.id], external: true });
|
|
||||||
} else if (isOntimeDelay(newEvent)) {
|
|
||||||
notifyChanges({ external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return newRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletes a block from the rundown and moves all its children to the top level
|
|
||||||
*/
|
|
||||||
export async function ungroupEntries(blockId: EntryId) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.ungroup);
|
|
||||||
const { newRundown } = await scopedMutation({ blockId });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// we dont need to modify the timer since the grouping does not affect the runtime
|
|
||||||
notifyChanges({ external: true });
|
|
||||||
|
|
||||||
return newRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Groups a list of entries into a block
|
|
||||||
*/
|
|
||||||
export async function groupEntries(entryIds: EntryId[]) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.groupEntries);
|
|
||||||
const { newRundown } = await scopedMutation({ entryIds });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// we dont need to modify the timer since the grouping does not affect the runtime
|
|
||||||
notifyChanges({ external: true });
|
|
||||||
|
|
||||||
return newRundown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* swaps two events
|
|
||||||
* @param {string} from - id of event from
|
|
||||||
* @param {string} to - id of event to
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function swapEvents(from: string, to: string) {
|
|
||||||
const scopedMutation = cache.mutateCache(cache.swap);
|
|
||||||
await scopedMutation({ fromId: from, toId: to });
|
|
||||||
|
|
||||||
// notify runtime that rundown has changed
|
|
||||||
updateRuntimeOnChange();
|
|
||||||
|
|
||||||
// notify timer and external services of change
|
|
||||||
notifyChanges({ timer: true, external: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces update in the store
|
* Forces update in the store
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
import { OntimeEvent, SupportedEntry } from 'ontime-types';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
|
||||||
|
|
||||||
import { apply } from '../delayUtils.js';
|
|
||||||
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
describe('apply()', () => {
|
|
||||||
it('applies a positive delay to the rundown', () => {
|
|
||||||
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: true }),
|
|
||||||
'3': makeOntimeBlock({ id: '3' }),
|
|
||||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
|
||||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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: true },
|
|
||||||
'3': { id: '3' },
|
|
||||||
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false },
|
|
||||||
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies negative delays', () => {
|
|
||||||
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: true }),
|
|
||||||
'3': makeOntimeBlock({ id: '3' }),
|
|
||||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
|
||||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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: false },
|
|
||||||
'3': { id: '3' },
|
|
||||||
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false },
|
|
||||||
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should account for minimum duration and start when applying negative delays', () => {
|
|
||||||
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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
apply('delay', testRundown);
|
|
||||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
|
||||||
expect(testRundown.entries).toMatchObject({
|
|
||||||
'1': {
|
|
||||||
id: '1',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
timeStart: 0,
|
|
||||||
timeEnd: 100,
|
|
||||||
duration: 100,
|
|
||||||
revision: 2,
|
|
||||||
} as OntimeEvent,
|
|
||||||
'2': {
|
|
||||||
id: '2',
|
|
||||||
type: SupportedEntry.Event,
|
|
||||||
timeStart: 50,
|
|
||||||
timeEnd: 100,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: false,
|
|
||||||
revision: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlinks events to maintain gaps when applying positive delays', () => {
|
|
||||||
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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
timeStart: 150,
|
|
||||||
timeEnd: 200,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: false,
|
|
||||||
revision: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maintains links if there is no gap', () => {
|
|
||||||
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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
timeStart: 150,
|
|
||||||
timeEnd: 200,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: true,
|
|
||||||
revision: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlinks events to maintain gaps when applying negative delays', () => {
|
|
||||||
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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
timeStart: 50,
|
|
||||||
timeEnd: 100,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: false,
|
|
||||||
revision: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gaps reduce positive delay', () => {
|
|
||||||
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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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)
|
|
||||||
'2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
|
||||||
// gap 50 (50 - 50)
|
|
||||||
'3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
|
|
||||||
// gap (delay is 0)
|
|
||||||
'4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
|
||||||
// linked
|
|
||||||
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gaps reduce positive delay (2)', () => {
|
|
||||||
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,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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)
|
|
||||||
'2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('removes empty delays without applying changes', () => {
|
|
||||||
const testRundown = makeRundown({
|
|
||||||
order: ['delay', '1'],
|
|
||||||
entries: {
|
|
||||||
delay: makeOntimeDelay({ id: 'delay', duration: 0 }),
|
|
||||||
'1': makeOntimeEvent({ 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 = makeRundown({
|
|
||||||
order: ['1', 'delay'],
|
|
||||||
entries: {
|
|
||||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
|
||||||
delay: makeOntimeDelay({ id: 'delay', 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 = 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: true }),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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',
|
|
||||||
timeStart: 150,
|
|
||||||
timeEnd: 200,
|
|
||||||
duration: 50,
|
|
||||||
linkStart: false,
|
|
||||||
revision: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,5 @@
|
|||||||
import { CustomFields, EndAction, OntimeEvent, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
import { CustomFields, SupportedEntry } from 'ontime-types';
|
||||||
import {
|
import { addToCustomAssignment, calculateDayOffset, handleCustomField } from '../rundownCache.utils.js';
|
||||||
addToCustomAssignment,
|
|
||||||
calculateDayOffset,
|
|
||||||
handleCustomField,
|
|
||||||
hasChanges,
|
|
||||||
isDataStale,
|
|
||||||
} from '../rundownCache.utils.js';
|
|
||||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||||
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||||
|
|
||||||
@@ -135,55 +129,6 @@ describe('handleCustomField()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isDataStale()', () => {
|
|
||||||
it('is stale if data contains timers', () => {
|
|
||||||
const needsRecompute = [
|
|
||||||
{ timeStart: 10 },
|
|
||||||
{ timeEnd: 10 },
|
|
||||||
{ duration: 10 },
|
|
||||||
{ linkStart: true },
|
|
||||||
{ timerStrategy: TimeStrategy.LockDuration },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const testCase of needsRecompute) {
|
|
||||||
expect(isDataStale(testCase)).toBe(true);
|
|
||||||
}
|
|
||||||
expect.assertions(needsRecompute.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is not stale if data contains auxiliary dataset', () => {
|
|
||||||
expect(
|
|
||||||
isDataStale({
|
|
||||||
cue: 'cue',
|
|
||||||
title: 'title',
|
|
||||||
note: 'note',
|
|
||||||
endAction: EndAction.LoadNext,
|
|
||||||
timerType: TimerType.Clock,
|
|
||||||
isPublic: false,
|
|
||||||
colour: 'colour',
|
|
||||||
timeWarning: 1,
|
|
||||||
timeDanger: 2,
|
|
||||||
custom: {
|
|
||||||
lighting: '3',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hasChanges()', () => {
|
|
||||||
it('identifies objects with new values', () => {
|
|
||||||
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
|
|
||||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
|
||||||
expect(hasChanges(existing, newEvent)).toBe(true);
|
|
||||||
});
|
|
||||||
it('identifies objects with all same values', () => {
|
|
||||||
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
|
|
||||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
|
||||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('calculateDayOffset', () => {
|
describe('calculateDayOffset', () => {
|
||||||
it('returns 0 if there is no previous event', () => {
|
it('returns 0 if there is no previous event', () => {
|
||||||
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
|
import { makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
import { getPreviousId } from '../rundownUtils.js';
|
import { getPreviousId } from '../rundownUtils.js';
|
||||||
|
|
||||||
// Mock cache module
|
|
||||||
vi.mock('../rundownCache.js', () => ({
|
|
||||||
getEventOrder: () => ({
|
|
||||||
flatOrder: ['a', 'b', 'c', 'd'],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('getPreviousId', () => {
|
describe('getPreviousId', () => {
|
||||||
|
const rundown = makeRundown({
|
||||||
|
flatOrder: ['a', 'b', 'c', 'd'],
|
||||||
|
});
|
||||||
|
|
||||||
it('returns afterId if provided', () => {
|
it('returns afterId if provided', () => {
|
||||||
expect(getPreviousId('b')).toBe('b');
|
expect(getPreviousId(rundown, 'b')).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the previous id before beforeId if provided', () => {
|
it('returns the previous id before beforeId if provided', () => {
|
||||||
expect(getPreviousId(undefined, 'c')).toBe('b');
|
expect(getPreviousId(rundown, undefined, 'c')).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
||||||
expect(getPreviousId()).toBeUndefined();
|
expect(getPreviousId(rundown)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined if beforeId is not found', () => {
|
it('returns undefined if beforeId is not found', () => {
|
||||||
expect(getPreviousId(undefined, 'z')).toBeUndefined();
|
expect(getPreviousId(rundown, undefined, 'z')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
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
|
|
||||||
* Mutates the given rundown
|
|
||||||
* @throws if event ID not found or is not a delay
|
|
||||||
*/
|
|
||||||
export function apply(delayId: EntryId, rundown: Rundown): Rundown {
|
|
||||||
const delayEvent = rundown.entries[delayId];
|
|
||||||
|
|
||||||
if (!delayEvent || !isOntimeDelay(delayEvent)) {
|
|
||||||
throw new Error('Given delay ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
*/
|
|
||||||
let delayValue = delayEvent.duration;
|
|
||||||
let lastEntry: OntimeEvent | null = null;
|
|
||||||
let isFirstEvent = true;
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to remove the link in the first event to maintain the gap
|
|
||||||
let shouldUnlink = isFirstEvent;
|
|
||||||
isFirstEvent = false;
|
|
||||||
|
|
||||||
// if the event is not linked, we try and maintain gaps
|
|
||||||
if (lastEntry !== null) {
|
|
||||||
// when applying negative delays, we need to unlink the event
|
|
||||||
// if the previous event was fully consumed by the delay
|
|
||||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
|
||||||
shouldUnlink = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentEntry.gap > 0) {
|
|
||||||
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delayValue === 0) {
|
|
||||||
// we can bail from continuing if there are no further delays to apply
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// save the current entry before making mutations on its values
|
|
||||||
lastEntry = { ...currentEntry };
|
|
||||||
|
|
||||||
if (shouldUnlink) {
|
|
||||||
currentEntry.linkStart = false;
|
|
||||||
shouldUnlink = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// event times move up by the delay value
|
|
||||||
// we dont update the delay value since we would need to iterate through the entire dataset
|
|
||||||
// this is handled by the rundownCache.generate function
|
|
||||||
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
|
|
||||||
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
|
|
||||||
currentEntry.revision += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete rundown.entries[delayId];
|
|
||||||
rundown.order = deleteAtIndex(delayIndex, rundown.order);
|
|
||||||
rundown.revision += 1;
|
|
||||||
|
|
||||||
return rundown;
|
|
||||||
}
|
|
||||||
@@ -7,27 +7,16 @@ import {
|
|||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
OntimeBlock,
|
OntimeBlock,
|
||||||
OntimeEvent,
|
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
Rundown,
|
Rundown,
|
||||||
RundownEntries,
|
RundownEntries,
|
||||||
OntimeDelay,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils';
|
import { generateId, insertAtIndex, customFieldLabelToKey } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js';
|
|
||||||
|
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||||
import { apply } from './delayUtils.js';
|
import { makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
|
||||||
import {
|
|
||||||
cloneBlock,
|
|
||||||
cloneEntry,
|
|
||||||
hasChanges,
|
|
||||||
isDataStale,
|
|
||||||
makeRundownMetadata,
|
|
||||||
type ProcessedRundownMetadata,
|
|
||||||
} from './rundownCache.utils.js';
|
|
||||||
|
|
||||||
let currentRundownId: EntryId = '';
|
let currentRundownId: EntryId = '';
|
||||||
let currentRundown: Rundown = {
|
let currentRundown: Rundown = {
|
||||||
@@ -373,370 +362,6 @@ export function add({ rundown, afterId, parent, entry }: AddArgs): Required<Muta
|
|||||||
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
|
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
|
|
||||||
/**
|
|
||||||
* Remove entries in a rundown
|
|
||||||
* It handles element relationships specifically when dealing with nested items
|
|
||||||
* - when removing a nested item, remove the reference from the parent block
|
|
||||||
* - when removing a block, remove all nested items
|
|
||||||
*/
|
|
||||||
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
|
|
||||||
/**
|
|
||||||
* changelist will hold a list of entries that need to be removed
|
|
||||||
* it will then be returned to the caller as a list of actually deleted entries
|
|
||||||
*/
|
|
||||||
const changeList: EntryId[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < eventIds.length; i++) {
|
|
||||||
const entry = rundown.entries[eventIds[i]];
|
|
||||||
// add the top level entry to the changeList
|
|
||||||
changeList.push(entry.id);
|
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
|
||||||
// for ontime blocks, we need to iterate through the children and delete them
|
|
||||||
changeList.concat([...entry.events]);
|
|
||||||
} else if (entry.parent) {
|
|
||||||
// at this point, we are handling entries inside a block, so we need to remove the references
|
|
||||||
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
|
|
||||||
const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]);
|
|
||||||
|
|
||||||
// we call a mutation to the parent event to
|
|
||||||
// - remove this entry from the events
|
|
||||||
// - reduce the children count
|
|
||||||
edit({
|
|
||||||
rundown,
|
|
||||||
eventId: entry.parent,
|
|
||||||
patch: {
|
|
||||||
events: parentEvents,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// delete all entries in the changeList
|
|
||||||
for (let i = 0; i < changeList.length; i++) {
|
|
||||||
const entryId = changeList[i];
|
|
||||||
rundown.order = rundown.order.filter((id) => id !== entryId);
|
|
||||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
|
|
||||||
delete rundown.entries[entryId];
|
|
||||||
}
|
|
||||||
|
|
||||||
const didMutate = changeList.length > 0;
|
|
||||||
if (didMutate) setIsStale();
|
|
||||||
return { newRundown: rundown, didMutate, changeList };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove all entries of a rundown
|
|
||||||
*/
|
|
||||||
export function removeAll(): MutatingReturn {
|
|
||||||
setIsStale();
|
|
||||||
return {
|
|
||||||
newRundown: {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
order: [],
|
|
||||||
flatOrder: [],
|
|
||||||
entries: {},
|
|
||||||
revision: 0,
|
|
||||||
},
|
|
||||||
didMutate: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility function for patching an existing event with new data
|
|
||||||
*/
|
|
||||||
function makeEvent<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
|
|
||||||
if (isOntimeEvent(eventFromRundown)) {
|
|
||||||
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
|
|
||||||
newEvent.revision++;
|
|
||||||
return newEvent as T;
|
|
||||||
}
|
|
||||||
if (isOntimeBlock(eventFromRundown)) {
|
|
||||||
const newEvent: OntimeBlock = { ...eventFromRundown, ...patch };
|
|
||||||
newEvent.revision++;
|
|
||||||
return newEvent as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...eventFromRundown, ...patch } as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// we cannot allow patching to a different type
|
|
||||||
if (patch?.type && entry.type !== patch.type) {
|
|
||||||
throw new Error('Invalid event type');
|
|
||||||
}
|
|
||||||
|
|
||||||
// if nothing changed, nothing to do
|
|
||||||
if (!hasChanges(entry, patch)) {
|
|
||||||
return { newRundown: rundown, changeList: [eventId], newEvent: entry, didMutate: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEvent = makeEvent(entry, patch);
|
|
||||||
rundown.entries[newEvent.id] = newEvent;
|
|
||||||
|
|
||||||
// check whether the data warrants recalculation of cache
|
|
||||||
const makeStale = isDataStale(patch);
|
|
||||||
|
|
||||||
if (makeStale) {
|
|
||||||
setIsStale();
|
|
||||||
} else {
|
|
||||||
rundown.entries[newEvent.id] = newEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { newRundown: rundown, changeList: [newEvent.id], newEvent, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial<OntimeEntry> }>;
|
|
||||||
/**
|
|
||||||
* Apply patch to multiple entries
|
|
||||||
*/
|
|
||||||
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
|
|
||||||
for (const eventId of eventIds) {
|
|
||||||
edit({ rundown, eventId, patch });
|
|
||||||
}
|
|
||||||
return { newRundown: rundown, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReorderArgs = MutationParams<{
|
|
||||||
entryId: EntryId;
|
|
||||||
destinationId: EntryId;
|
|
||||||
order: 'before' | 'after' | 'insert';
|
|
||||||
}>;
|
|
||||||
/**
|
|
||||||
* Moves an event to a new position in the rundown
|
|
||||||
* Handles moving across root orders (a block order and top level order)
|
|
||||||
* @throws if entryId or destinationId not found
|
|
||||||
* @throws if trying to insert an event into a block inside another block
|
|
||||||
*/
|
|
||||||
export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required<MutatingReturn> {
|
|
||||||
const eventFrom = rundown.entries[entryId];
|
|
||||||
const eventTo = rundown.entries[destinationId];
|
|
||||||
|
|
||||||
if (!eventFrom || !eventTo) {
|
|
||||||
throw new Error('Event not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null;
|
|
||||||
const toParent = (() => {
|
|
||||||
if (isOntimeBlock(eventTo)) {
|
|
||||||
if (order === 'insert') {
|
|
||||||
return eventTo.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return eventTo.parent ?? null;
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!isOntimeBlock(eventFrom)) {
|
|
||||||
eventFrom.parent = toParent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events;
|
|
||||||
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events;
|
|
||||||
|
|
||||||
const fromIndex = sourceArray.indexOf(entryId);
|
|
||||||
const toIndex = (() => {
|
|
||||||
const baseIndex = destinationArray.indexOf(destinationId);
|
|
||||||
if (order === 'before') return baseIndex;
|
|
||||||
// only add one if we are moving down
|
|
||||||
if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1);
|
|
||||||
// for insert we add in the end of the array
|
|
||||||
return destinationArray.length;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Remove from source array
|
|
||||||
sourceArray.splice(fromIndex, 1);
|
|
||||||
// Insert into destination array
|
|
||||||
destinationArray.splice(toIndex, 0, entryId);
|
|
||||||
|
|
||||||
// changelist is derived from the flat order
|
|
||||||
const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length);
|
|
||||||
|
|
||||||
return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
|
|
||||||
/**
|
|
||||||
* Apply a delay
|
|
||||||
* Mutates the given rundown
|
|
||||||
*/
|
|
||||||
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
|
|
||||||
apply(delayId, rundown);
|
|
||||||
setIsStale();
|
|
||||||
return { newRundown: rundown, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type CloneEntryArgs = MutationParams<{ entryId: EntryId }>;
|
|
||||||
/**
|
|
||||||
* Apply a delay
|
|
||||||
* Mutates the given rundown
|
|
||||||
*/
|
|
||||||
export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn {
|
|
||||||
const entry = rundown.entries[entryId];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error('Entry not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
|
||||||
const newBlock = cloneBlock(entry, getUniqueId());
|
|
||||||
const nestedIds: EntryId[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < entry.events.length; i++) {
|
|
||||||
const nestedEntryId = entry.events[i];
|
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
|
||||||
if (!nestedEntry) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// clone the event and assign it to the new block
|
|
||||||
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId());
|
|
||||||
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id;
|
|
||||||
|
|
||||||
nestedIds.push(newNestedEntry.id);
|
|
||||||
// we immediately insert the nested entries into the rundown
|
|
||||||
rundown.entries[newNestedEntry.id] = newNestedEntry;
|
|
||||||
}
|
|
||||||
// indexes + 1 since we are inserting after the cloned block
|
|
||||||
const atIndex = rundown.order.indexOf(entryId) + 1;
|
|
||||||
// we need to find the index of the last entry
|
|
||||||
const lastNestedIdInOriginal = entry.events.at(-1) ?? '0';
|
|
||||||
const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1;
|
|
||||||
|
|
||||||
newBlock.events = nestedIds;
|
|
||||||
newBlock.title = `${entry.title || 'Untitled'} (copy)`;
|
|
||||||
|
|
||||||
rundown.entries[newBlock.id] = newBlock;
|
|
||||||
rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order);
|
|
||||||
rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder);
|
|
||||||
|
|
||||||
return { newRundown: rundown, didMutate: true, newEvent: newBlock };
|
|
||||||
} else {
|
|
||||||
return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type UngroupArgs = MutationParams<{ blockId: EntryId }>;
|
|
||||||
/**
|
|
||||||
* Deletes a block and moves all its children to the top level order
|
|
||||||
* Mutates the given rundown
|
|
||||||
* @throws if block ID not found
|
|
||||||
*/
|
|
||||||
export function ungroup({ rundown, blockId }: UngroupArgs): MutatingReturn {
|
|
||||||
const block = rundown.entries[blockId];
|
|
||||||
if (!isOntimeBlock(block)) {
|
|
||||||
throw new Error('Block with ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the events from the block and merge them into the order where the block was
|
|
||||||
const nestedEvents = block.events;
|
|
||||||
const blockIndex = rundown.order.indexOf(blockId);
|
|
||||||
rundown.order.splice(blockIndex, 1, ...nestedEvents);
|
|
||||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId);
|
|
||||||
|
|
||||||
// delete block from entries and remove its reference from the child events
|
|
||||||
delete rundown.entries[blockId];
|
|
||||||
for (let i = 0; i < nestedEvents.length; i++) {
|
|
||||||
const eventId = nestedEvents[i];
|
|
||||||
const entry = rundown.entries[eventId];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error('Entry not found');
|
|
||||||
}
|
|
||||||
(entry as OntimeEvent | OntimeDelay).parent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { newRundown: rundown, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type GroupArgs = MutationParams<{ entryIds: EntryId[] }>;
|
|
||||||
/**
|
|
||||||
* Groups a list of entries into a block
|
|
||||||
* It ensures that the entries get reassigned parent and the block gets a list of events
|
|
||||||
* The block will be created at the index of the first event in the order, not at the lowest index
|
|
||||||
* Mutates the given rundown
|
|
||||||
* @throws if any of the entries is a block
|
|
||||||
* @throws if any of the entries is not found
|
|
||||||
*/
|
|
||||||
export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn {
|
|
||||||
const block = createBlock({ id: getUniqueId() });
|
|
||||||
|
|
||||||
const nestedEvents: EntryId[] = [];
|
|
||||||
let firstIndex = -1;
|
|
||||||
for (let i = 0; i < entryIds.length; i++) {
|
|
||||||
const entryId = entryIds[i];
|
|
||||||
const entry = rundown.entries[entryId];
|
|
||||||
if (!entry) {
|
|
||||||
throw new Error('Entry not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOntimeBlock(entry)) {
|
|
||||||
throw new Error('Cannot group a block');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.parent !== null) {
|
|
||||||
throw new Error('Entry already has a parent');
|
|
||||||
}
|
|
||||||
|
|
||||||
// the block will be created at the first selected event position
|
|
||||||
// note that this is not the lowest index
|
|
||||||
if (firstIndex === -1) {
|
|
||||||
firstIndex = rundown.flatOrder.indexOf(entryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
nestedEvents.push(entryId);
|
|
||||||
entry.parent = block.id;
|
|
||||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
|
|
||||||
rundown.order = rundown.order.filter((id) => id !== entryId);
|
|
||||||
}
|
|
||||||
|
|
||||||
block.events = nestedEvents;
|
|
||||||
const insertIndex = Math.max(0, firstIndex);
|
|
||||||
// we have filtered the items from the order
|
|
||||||
// we will insert them now, with only the block at top level ...
|
|
||||||
rundown.order = insertAtIndex(insertIndex, block.id, rundown.order);
|
|
||||||
/// ... and the nested elements after the block in the flat order
|
|
||||||
rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder);
|
|
||||||
rundown.entries[block.id] = block;
|
|
||||||
|
|
||||||
return { newRundown: rundown, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>;
|
|
||||||
/**
|
|
||||||
* Swap two entries
|
|
||||||
*/
|
|
||||||
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
|
|
||||||
const fromEvent = rundown.entries[fromId];
|
|
||||||
const toEvent = rundown.entries[toId];
|
|
||||||
|
|
||||||
if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) {
|
|
||||||
throw new Error('Swap only available for OntimeEvents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [newFrom, newTo] = swapEventData(fromEvent, toEvent);
|
|
||||||
|
|
||||||
rundown.entries[fromId] = newFrom;
|
|
||||||
rundown.entries[toId] = newTo;
|
|
||||||
newFrom.revision++;
|
|
||||||
newTo.revision++;
|
|
||||||
|
|
||||||
setIsStale();
|
|
||||||
return { newRundown: rundown, didMutate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility for invalidating service cache if a custom field is used
|
* Utility for invalidating service cache if a custom field is used
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,19 +3,16 @@ import {
|
|||||||
CustomFieldLabel,
|
CustomFieldLabel,
|
||||||
CustomFields,
|
CustomFields,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
OntimeBaseEvent,
|
|
||||||
EntryId,
|
EntryId,
|
||||||
isOntimeEvent,
|
isOntimeEvent,
|
||||||
isPlayableEvent,
|
isPlayableEvent,
|
||||||
isOntimeDelay,
|
isOntimeDelay,
|
||||||
PlayableEvent,
|
PlayableEvent,
|
||||||
RundownEntries,
|
RundownEntries,
|
||||||
OntimeDelay,
|
|
||||||
OntimeBlock,
|
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||||
|
|
||||||
import type { RundownMetadata } from './rundown.types.js';
|
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||||
@@ -65,49 +62,6 @@ export function handleCustomField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
|
||||||
enum RegenerateWhitelist {
|
|
||||||
'id',
|
|
||||||
'cue',
|
|
||||||
'title',
|
|
||||||
'note',
|
|
||||||
'endAction',
|
|
||||||
'timerType',
|
|
||||||
'countToEnd',
|
|
||||||
'isPublic',
|
|
||||||
'colour',
|
|
||||||
'timeWarning',
|
|
||||||
'timeDanger',
|
|
||||||
'custom',
|
|
||||||
'triggers',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* given a patch, returns whether all keys are whitelisted
|
|
||||||
*/
|
|
||||||
export function isDataStale(patch: Partial<OntimeEntry>): boolean {
|
|
||||||
return Object.keys(patch).some(willCauseRegeneration);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* given a key, returns whether it is whitelisted
|
|
||||||
*/
|
|
||||||
export function willCauseRegeneration(key: string): boolean {
|
|
||||||
return !(key in RegenerateWhitelist);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Given an event and a patch to that event checks whether there are actual changes to the dataset
|
|
||||||
* @param existingEvent
|
|
||||||
* @param newEvent
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
|
||||||
return Object.keys(newEvent).some(
|
|
||||||
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility for calculating if the current events should have a day offset
|
* Utility for calculating if the current events should have a day offset
|
||||||
* @param current the current event under test
|
* @param current the current event under test
|
||||||
@@ -149,6 +103,10 @@ export type ProcessedRundownMetadata = RundownMetadata & {
|
|||||||
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory function to create a rundown metadata processor
|
||||||
|
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
||||||
|
*/
|
||||||
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
|
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
|
||||||
let rundownMeta: ProcessedRundownMetadata = {
|
let rundownMeta: ProcessedRundownMetadata = {
|
||||||
totalDelay: 0,
|
totalDelay: 0,
|
||||||
@@ -185,6 +143,9 @@ export function makeRundownMetadata(customFields: CustomFields, customFieldChang
|
|||||||
return { process, getMetadata };
|
return { process, getMetadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes a single entry and updates the rundown metadata
|
||||||
|
*/
|
||||||
function processEntry<T extends OntimeEntry>(
|
function processEntry<T extends OntimeEntry>(
|
||||||
rundownMetadata: ProcessedRundownMetadata,
|
rundownMetadata: ProcessedRundownMetadata,
|
||||||
customFields: CustomFields,
|
customFields: CustomFields,
|
||||||
@@ -288,37 +249,3 @@ function processEntry<T extends OntimeEntry>(
|
|||||||
|
|
||||||
return { processedData, processedEntry: currentEntry };
|
return { processedData, processedEntry: currentEntry };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent {
|
|
||||||
const newEntry = structuredClone(entry);
|
|
||||||
newEntry.id = newId;
|
|
||||||
newEntry.revision = 0;
|
|
||||||
return newEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
|
|
||||||
const newEntry = structuredClone(entry);
|
|
||||||
newEntry.id = newId;
|
|
||||||
return newEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
|
|
||||||
const newEntry = structuredClone(entry);
|
|
||||||
newEntry.id = newId;
|
|
||||||
|
|
||||||
// in blocks, we need to remove the events references
|
|
||||||
newEntry.events = [];
|
|
||||||
newEntry.revision = 0;
|
|
||||||
return newEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
|
|
||||||
if (isOntimeEvent(entry)) {
|
|
||||||
return cloneEvent(entry, newId) as T;
|
|
||||||
} else if (isOntimeDelay(entry)) {
|
|
||||||
return cloneDelay(entry, newId) as T;
|
|
||||||
} else if (entry.type === 'block') {
|
|
||||||
return cloneBlock(entry as OntimeBlock, newId) as T;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,21 +8,16 @@ import {
|
|||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import * as cache from './rundownCache.js';
|
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
|
||||||
/**
|
import * as cache from './rundownCache.js';
|
||||||
* returns entire unfiltered rundown
|
|
||||||
*/
|
|
||||||
export function getCurrentRundown(): Rundown {
|
|
||||||
return cache.getCurrentRundown();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* returns the the project rundown and the order arrays
|
* returns the the project rundown and the order arrays
|
||||||
*/
|
*/
|
||||||
export function getRundownData() {
|
export function getRundownData() {
|
||||||
return {
|
return {
|
||||||
rundown: cache.getCurrentRundown(),
|
rundown: getCurrentRundown(),
|
||||||
rundownOrder: cache.getEventOrder(),
|
rundownOrder: cache.getEventOrder(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,17 +173,16 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string):
|
|||||||
* Receives an insertion order and returns the reference to an event ID
|
* Receives an insertion order and returns the reference to an event ID
|
||||||
* after which we will insert the new event
|
* after which we will insert the new event
|
||||||
*/
|
*/
|
||||||
export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined {
|
export function getPreviousId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
||||||
if (afterId) {
|
if (afterId) {
|
||||||
return afterId;
|
return afterId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (beforeId) {
|
if (beforeId) {
|
||||||
const flatOrder = cache.getEventOrder().flatOrder;
|
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
||||||
const atIndex = flatOrder.findIndex((id) => id === beforeId);
|
if (atIndex < 1) return null;
|
||||||
if (atIndex < 1) return undefined;
|
return rundown.flatOrder[atIndex - 1];
|
||||||
return flatOrder[atIndex - 1];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { eventStore } from '../../stores/EventStore.js';
|
|||||||
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
import { triggerReportEntry } from '../../api-data/report/report.service.js';
|
||||||
import { timerConfig } from '../../setup/config.js';
|
import { timerConfig } from '../../setup/config.js';
|
||||||
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
import { triggerAutomations } from '../../api-data/automation/automation.service.js';
|
||||||
|
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
|
||||||
import { EventTimer } from '../EventTimer.js';
|
import { EventTimer } from '../EventTimer.js';
|
||||||
import { RestorePoint, restoreService } from '../RestoreService.js';
|
import { RestorePoint, restoreService } from '../RestoreService.js';
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
getEventAtIndex,
|
getEventAtIndex,
|
||||||
getNextEventWithCue,
|
getNextEventWithCue,
|
||||||
getEntryWithId,
|
getEntryWithId,
|
||||||
getCurrentRundown,
|
|
||||||
getTimedEvents,
|
getTimedEvents,
|
||||||
getRundownData,
|
getRundownData,
|
||||||
} from '../rundown-service/rundownUtils.js';
|
} from '../rundown-service/rundownUtils.js';
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import got from 'got';
|
|||||||
import { parseExcel } from '../../utils/parser.js';
|
import { parseExcel } from '../../utils/parser.js';
|
||||||
import { logger } from '../../classes/Logger.js';
|
import { logger } from '../../classes/Logger.js';
|
||||||
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
|
||||||
|
import { getCurrentRundown, getProjectCustomFields } from '../../api-data/rundown/rundown.dao.js';
|
||||||
|
|
||||||
import { getCurrentRundown, getCustomFields } from '../rundown-service/rundownCache.js';
|
|
||||||
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
import { getRundownOrThrow } from '../rundown-service/rundownUtils.js';
|
||||||
|
|
||||||
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
|
import { cellRequestFromEvent, type ClientSecret, getA1Notation, isClientSecret } from './sheetUtils.js';
|
||||||
@@ -323,7 +323,7 @@ export async function upload(sheetId: string, options: ImportMap) {
|
|||||||
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
|
throw new Error(`Sheet read failed: ${readResponse.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rundownMetadata } = parseExcel(readResponse.data.values, getCustomFields(), 'not-used', options);
|
const { rundownMetadata } = parseExcel(readResponse.data.values, getProjectCustomFields(), 'not-used', options);
|
||||||
const rundown = getCurrentRundown();
|
const rundown = getCurrentRundown();
|
||||||
const titleRow = Object.values(rundownMetadata)[0]['row'];
|
const titleRow = Object.values(rundownMetadata)[0]['row'];
|
||||||
const updateRundown = Array<sheets_v4.Schema$Request>();
|
const updateRundown = Array<sheets_v4.Schema$Request>();
|
||||||
@@ -409,7 +409,7 @@ export async function download(
|
|||||||
throw new Error('Sheet: No data found in the worksheet');
|
throw new Error('Sheet: No data found in the worksheet');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataFromSheet = parseExcel(googleResponse.data.values, getCustomFields(), 'Rundown', options);
|
const dataFromSheet = parseExcel(googleResponse.data.values, getProjectCustomFields(), 'Rundown', options);
|
||||||
|
|
||||||
const rundownId = dataFromSheet.rundown.id;
|
const rundownId = dataFromSheet.rundown.id;
|
||||||
const dataModel: Pick<DatabaseModel, 'rundowns' | 'customFields'> = {
|
const dataModel: Pick<DatabaseModel, 'rundowns' | 'customFields'> = {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
|
||||||
|
|
||||||
import { initRundown } from '../../services/rundown-service/RundownService.js';
|
import { makeOntimeBlock, makeOntimeEvent, makeRundown } from '../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||||
import {
|
import { initRundown } from '../../api-data/rundown/rundown.service.js';
|
||||||
makeOntimeBlock,
|
|
||||||
makeOntimeEvent,
|
|
||||||
makeRundown,
|
|
||||||
} from '../../services/rundown-service/__mocks__/rundown.mocks.js';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type RuntimeState,
|
type RuntimeState,
|
||||||
@@ -71,18 +67,6 @@ beforeAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mutation on runtimeState', () => {
|
describe('mutation on runtimeState', () => {
|
||||||
beforeEach(async () => {
|
|
||||||
clearState();
|
|
||||||
vi.mock('../../services/rundown-service/RundownService.js', async (importOriginal) => {
|
|
||||||
const actual = (await importOriginal()) as object;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
initRunddown: vi.fn().mockReturnValue(undefined),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OntimeBlock, OntimeDelay, OntimeEvent } from '../../definitions/core/OntimeEvent.type.js';
|
|
||||||
import type { OntimeEntry } from '../../definitions/core/Rundown.type.js';
|
import type { OntimeEntry } from '../../definitions/core/Rundown.type.js';
|
||||||
|
|
||||||
export type PatchWithId = Partial<OntimeEvent | OntimeDelay | OntimeBlock> & { id: string };
|
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: string };
|
||||||
|
|
||||||
export type EventPostPayload = Partial<OntimeEntry> & {
|
export type EventPostPayload = Partial<OntimeEntry> & {
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('getCueCandidate()', () => {
|
|||||||
'1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent,
|
'2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2']);
|
const cue = getCueCandidate(entries, ['1', '2'], null);
|
||||||
expect(cue).toBe('1');
|
expect(cue).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ describe('getCueCandidate()', () => {
|
|||||||
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
'2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2']);
|
const cue = getCueCandidate(entries, ['1', '2'], null);
|
||||||
expect(cue).toBe('0.1');
|
expect(cue).toBe('0.1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -124,7 +124,7 @@ describe('findCueName() with mixed events', () => {
|
|||||||
'1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent,
|
'2': { id: '2', cue: '11', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2']);
|
const cue = getCueCandidate(entries, ['1', '2'], null);
|
||||||
expect(cue).toBe('1');
|
expect(cue).toBe('1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ describe('findCueName() with mixed events', () => {
|
|||||||
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
'2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2']);
|
const cue = getCueCandidate(entries, ['1', '2'], null);
|
||||||
expect(cue).toBe('0.1');
|
expect(cue).toBe('0.1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ export function getIncrement(input: string): string {
|
|||||||
/**
|
/**
|
||||||
* Gets suitable name for a new event cue
|
* Gets suitable name for a new event cue
|
||||||
*/
|
*/
|
||||||
export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId?: string): string {
|
export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId: EntryId | null): string {
|
||||||
// we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1
|
// we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1
|
||||||
if (insertAfterId === undefined || order.length === 0) {
|
if (insertAfterId === null || order.length === 0) {
|
||||||
return addAtTop();
|
return addAtTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user