mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
refactor: normalise data (#756)
* chore: remove legal from bundle * refactor: create normalised dataset * refactor: cuesheet uses flat rundown * refactor: multi-selection * refactor: prevent stale data on server restart * refactor: increase ID size * chore: instrument operation * chore: update csv tests * fix: resolve directory to test-db (#758)
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { LogOrigin, OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
||||
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
||||
import { editEvent, getEventWithId } from '../services/rundown-service/RundownService.js';
|
||||
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
const whitelistedPayload = {
|
||||
title: coerceString,
|
||||
@@ -62,9 +61,7 @@ export function updateEvent(
|
||||
propertiesToUpdate.timeEnd = event.timeStart + propertiesToUpdate.duration;
|
||||
}
|
||||
|
||||
editEvent({ id: eventId, ...propertiesToUpdate }).then(() => {
|
||||
logger.info(LogOrigin.Playback, `Updated ${propertyName} of event with ID ${eventId} to ${newValue}`);
|
||||
});
|
||||
editEvent({ id: eventId, ...propertiesToUpdate });
|
||||
} else {
|
||||
throw new Error(`Event with ID ${eventId} not found`);
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ import {
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
||||
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
||||
import { delayedRundownCacheKey } from '../services/rundown-service/rundownCache.js';
|
||||
import { deleteAllEvents, notifyChanges, setRundown } from '../services/rundown-service/RundownService.js';
|
||||
import { integrationService } from '../services/integration-service/IntegrationService.js';
|
||||
import { getProjectFiles } from '../utils/getFileListFromFolder.js';
|
||||
import { configService } from '../services/ConfigService.js';
|
||||
@@ -101,12 +99,14 @@ const parseAndApply = async (file, _req, res, options) => {
|
||||
runtimeService.stop();
|
||||
|
||||
const newRundown = result.rundown || [];
|
||||
const { rundown, ...rest } = result;
|
||||
if (options?.onlyRundown === 'true') {
|
||||
await DataProvider.setRundown(newRundown);
|
||||
setRundown(newRundown ?? []);
|
||||
} else {
|
||||
await DataProvider.mergeIntoData(result);
|
||||
await DataProvider.mergeIntoData(rest);
|
||||
setRundown(rundown ?? []);
|
||||
}
|
||||
notifyChanges({ timer: true, external: true, reset: true });
|
||||
notifyChanges({ timer: true, external: true });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -403,15 +403,14 @@ export async function patchPartialProjectFile(req, res) {
|
||||
osc: req.body?.osc,
|
||||
aliases: req.body?.aliases,
|
||||
userFields: req.body?.userFields,
|
||||
rundown: req.body?.rundown,
|
||||
};
|
||||
|
||||
const maybeRundown = req.body?.rundown;
|
||||
await DataProvider.mergeIntoData(patchDb);
|
||||
if (patchDb.rundown !== undefined) {
|
||||
if (maybeRundown !== undefined) {
|
||||
// it is likely cheaper to invalidate cache than to calculate diff
|
||||
runtimeService.stop();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
notifyChanges({ external: true, reset: true });
|
||||
await setRundown(maybeRundown);
|
||||
}
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
@@ -453,7 +452,6 @@ export async function previewExcel(req, res) {
|
||||
const data = await parseFile(file, req, res, options);
|
||||
res.status(200).send(data);
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
res.status(500).send({ message: error.toString() });
|
||||
}
|
||||
}
|
||||
@@ -626,6 +624,7 @@ export const createProjectFile: RequestHandler = async (req, res) => {
|
||||
return res.status(409).send({ message: errors.join(', ') });
|
||||
}
|
||||
|
||||
console.log(`----------------> Creating directory createProjectFile: ${projectFilePath}`);
|
||||
await writeFile(projectFilePath, JSON.stringify(dbModel));
|
||||
|
||||
res.status(200).send({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { GetRundownCached } from 'ontime-types';
|
||||
import { RundownCached } from 'ontime-types';
|
||||
|
||||
import { Request, Response, RequestHandler } from 'express';
|
||||
|
||||
@@ -13,19 +13,20 @@ import {
|
||||
reorderEvent,
|
||||
swapEvents,
|
||||
} from '../services/rundown-service/RundownService.js';
|
||||
import { getDelayedRundown, getRundownCache } from '../services/rundown-service/rundownCache.js';
|
||||
import { get } from '../services/rundown-service/rundownCache.js';
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
|
||||
// Create controller for GET request to '/events'
|
||||
// Returns -
|
||||
export const rundownGetAll: RequestHandler = async (_req, res) => {
|
||||
const delayedRundown = getDelayedRundown();
|
||||
res.json(delayedRundown);
|
||||
const rundown = DataProvider.getRundown();
|
||||
res.json(rundown);
|
||||
};
|
||||
|
||||
// Create controller for GET request to '/events/cached'
|
||||
// Returns -
|
||||
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<GetRundownCached>) => {
|
||||
const cachedRundown = getRundownCache();
|
||||
export const rundownGetCached: RequestHandler = async (_req: Request, res: Response<RundownCached>) => {
|
||||
const cachedRundown = get();
|
||||
res.json(cachedRundown);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
|
||||
export const delay: Omit<OntimeDelay, 'id'> = {
|
||||
duration: 0,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
export const block: Omit<OntimeBlock, 'id'> = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
This directory holds the demo file shipped with Ontime
|
||||
|
||||
@@ -1,119 +1,87 @@
|
||||
import {
|
||||
LogOrigin,
|
||||
OntimeBaseEvent,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
SupportedEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
} from 'ontime-types';
|
||||
import { generateId, getCueCandidate } from 'ontime-utils';
|
||||
import { getCueCandidate } from 'ontime-utils';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
|
||||
import { sendRefetch } from '../../adapters/websocketAux.js';
|
||||
import { runtimeCacheStore } from '../../stores/cachingStore.js';
|
||||
import {
|
||||
cachedAdd,
|
||||
cachedApplyDelay,
|
||||
cachedClear,
|
||||
cachedDelete,
|
||||
cachedEdit,
|
||||
cachedBatchEdit,
|
||||
cachedReorder,
|
||||
cachedSwap,
|
||||
delayedRundownCacheKey,
|
||||
} from './rundownCache.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { createEvent } from '../../utils/parser.js';
|
||||
import { updateNumEvents } from '../../stores/runtimeState.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
|
||||
/**
|
||||
* Forces rundown to be recalculated
|
||||
* To be used when we know the rundown has changed completely
|
||||
*/
|
||||
export function forceReset() {
|
||||
runtimeService.reset();
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
import * as cache from './rundownCache.js';
|
||||
|
||||
function generateEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
|
||||
// we discard any UI provided events and add our own
|
||||
const id = cache.getUniqueId();
|
||||
|
||||
if (isOntimeEvent(eventData)) {
|
||||
return createEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent;
|
||||
}
|
||||
|
||||
if (isOntimeDelay(eventData)) {
|
||||
return { ...delayDef, duration: eventData.duration ?? 0, id } as OntimeDelay;
|
||||
}
|
||||
|
||||
if (isOntimeBlock(eventData)) {
|
||||
return { ...blockDef, title: eventData?.title ?? '', id } as OntimeBlock;
|
||||
}
|
||||
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
|
||||
/**
|
||||
* @description creates a new event with given data
|
||||
* @param {object} eventData
|
||||
* @return {unknown[]}
|
||||
* @return {OntimeRundownEntry}
|
||||
*/
|
||||
export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>) {
|
||||
let newEvent: Partial<OntimeBaseEvent> = {};
|
||||
const id = generateId();
|
||||
|
||||
let insertIndex = 0;
|
||||
// if the user didnt provide an index, we add the event to start
|
||||
let atIndex = 0;
|
||||
if (eventData?.after !== undefined) {
|
||||
const index = DataProvider.getIndexOf(eventData.after);
|
||||
if (index < 0) {
|
||||
const previousIndex = cache.getIndexOf(eventData.after);
|
||||
if (previousIndex < 0) {
|
||||
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`);
|
||||
} else {
|
||||
insertIndex = index + 1;
|
||||
atIndex = previousIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
switch (eventData.type) {
|
||||
case SupportedEvent.Event: {
|
||||
newEvent = createEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent;
|
||||
break;
|
||||
}
|
||||
case SupportedEvent.Delay:
|
||||
newEvent = { ...delayDef, duration: eventData.duration, id } as OntimeDelay;
|
||||
break;
|
||||
case SupportedEvent.Block:
|
||||
newEvent = { ...blockDef, title: eventData.title, id } as OntimeBlock;
|
||||
break;
|
||||
}
|
||||
delete eventData.after;
|
||||
|
||||
// generate a fully formed event from the patch
|
||||
const eventToAdd = generateEvent(eventData);
|
||||
// modify rundown
|
||||
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
|
||||
|
||||
notifyChanges({ timer: [id], external: true });
|
||||
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
|
||||
if (!eventData?.id) {
|
||||
throw new Error('Event misses ID');
|
||||
}
|
||||
if (isOntimeEvent(eventData) && eventData?.cue === '') {
|
||||
throw new Error('Cue value invalid');
|
||||
}
|
||||
|
||||
const newEvent = await cachedEdit(eventData.id, eventData);
|
||||
const scopedMutation = cache.mutateCache(cache.add);
|
||||
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd as OntimeRundownEntry });
|
||||
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
|
||||
// notify runtime that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
|
||||
await cachedBatchEdit(ids, data);
|
||||
|
||||
// notify runtime service of changed events
|
||||
runtimeService.update(ids);
|
||||
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes event by its ID
|
||||
* @param eventId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteEvent(eventId: string) {
|
||||
await cachedDelete(eventId);
|
||||
const scopedMutation = cache.mutateCache(cache.remove);
|
||||
await scopedMutation({ eventId });
|
||||
|
||||
notifyChanges({ timer: [eventId], external: true });
|
||||
|
||||
// notify event loader that rundown size has changed
|
||||
updateChangeNumEvents();
|
||||
}
|
||||
@@ -123,10 +91,31 @@ export async function deleteEvent(eventId: string) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteAllEvents() {
|
||||
await cachedClear();
|
||||
const scopedMutation = cache.mutateCache(cache.removeAll);
|
||||
await scopedMutation({});
|
||||
|
||||
// no need to modify timer since we will reset
|
||||
notifyChanges({ external: true, reset: true });
|
||||
notifyChanges({ external: true });
|
||||
}
|
||||
|
||||
export async function editEvent(patch: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
|
||||
if (isOntimeEvent(patch) && patch?.cue === '') {
|
||||
throw new Error('Cue value invalid');
|
||||
}
|
||||
|
||||
const scopedMutation = cache.mutateCache(cache.edit);
|
||||
const { newEvent } = await scopedMutation({ patch, eventId: patch.id });
|
||||
|
||||
notifyChanges({ timer: [patch.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
|
||||
const scopedMutation = cache.mutateCache(cache.batchEdit);
|
||||
await scopedMutation({ patch: data, eventIds: ids });
|
||||
|
||||
notifyChanges({ timer: ids, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,7 +126,8 @@ export async function deleteAllEvents() {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
const reorderedItem = await cachedReorder(eventId, from, to);
|
||||
const scopedMutation = cache.mutateCache(cache.reorder);
|
||||
const reorderedItem = await scopedMutation({ eventId, from, to });
|
||||
|
||||
notifyChanges({ timer: true, external: true });
|
||||
|
||||
@@ -145,7 +135,8 @@ export async function reorderEvent(eventId: string, from: number, to: number) {
|
||||
}
|
||||
|
||||
export async function applyDelay(eventId: string) {
|
||||
await cachedApplyDelay(eventId);
|
||||
const scopedMutation = cache.mutateCache(cache.applyDelay);
|
||||
await scopedMutation({ eventId });
|
||||
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
@@ -157,7 +148,8 @@ export async function applyDelay(eventId: string) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function swapEvents(from: string, to: string) {
|
||||
await cachedSwap(from, to);
|
||||
const scopedMutation = cache.mutateCache(cache.swap);
|
||||
await scopedMutation({ fromId: from, toId: to });
|
||||
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
@@ -174,7 +166,7 @@ function updateChangeNumEvents() {
|
||||
/**
|
||||
* Notify services of changes in the rundown
|
||||
*/
|
||||
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean; reset?: boolean }) {
|
||||
export function notifyChanges(options: { timer?: boolean | string[]; external?: boolean }) {
|
||||
if (options.timer) {
|
||||
// notify timer service of changed events
|
||||
// timer can be true or an array of changed IDs
|
||||
@@ -184,17 +176,20 @@ export function notifyChanges(options: { timer?: boolean | string[]; external?:
|
||||
runtimeService.update();
|
||||
}
|
||||
|
||||
if (options.reset) {
|
||||
// force rundown to be recalculated
|
||||
forceReset();
|
||||
}
|
||||
|
||||
if (options.external) {
|
||||
// advice socket subscribers of change
|
||||
sendRefetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* returns entire unfiltered rundown
|
||||
* @return {array}
|
||||
*/
|
||||
export function getRundown(): OntimeRundown {
|
||||
return DataProvider.getRundown();
|
||||
}
|
||||
|
||||
/**
|
||||
* returns all events of type OntimeEvent
|
||||
* @return {array}
|
||||
@@ -290,3 +285,9 @@ export function findNext(currentEventId?: string): OntimeEvent | null {
|
||||
const nextEvent = timedEvents.at(newIndex);
|
||||
return nextEvent ?? null;
|
||||
}
|
||||
|
||||
export async function setRundown(rundown: OntimeRundown) {
|
||||
await DataProvider.setRundown(rundown);
|
||||
cache.init(rundown);
|
||||
notifyChanges({ timer: true });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
|
||||
import { applyDelay } from '../delayUtils.js';
|
||||
import { apply } from '../delayUtils.js';
|
||||
|
||||
describe('_applyDelay() ', () => {
|
||||
describe('apply() ', () => {
|
||||
describe('in a rundown without the delay field, persisted rundown', () => {
|
||||
it('applies delays', () => {
|
||||
const delayId = '1';
|
||||
@@ -20,7 +20,7 @@ describe('_applyDelay() ', () => {
|
||||
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
it('applies negative delays', () => {
|
||||
@@ -40,7 +40,7 @@ describe('_applyDelay() ', () => {
|
||||
{ id: '5', type: SupportedEvent.Event, timeStart: 0, timeEnd: 10, duration: 10, revision: 1 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
it('maintains constant duration', () => {
|
||||
@@ -56,7 +56,7 @@ describe('_applyDelay() ', () => {
|
||||
{ id: '3', type: SupportedEvent.Event, timeStart: 0, timeEnd: 20, duration: 20, revision: 2 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('_applyDelay() ', () => {
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
it('applies negative delays', () => {
|
||||
@@ -194,7 +194,7 @@ describe('_applyDelay() ', () => {
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
it('maintains constant duration', () => {
|
||||
@@ -242,7 +242,7 @@ describe('_applyDelay() ', () => {
|
||||
} as OntimeEvent,
|
||||
];
|
||||
|
||||
const updatedRundown = applyDelay(delayId, testRundown);
|
||||
const updatedRundown = apply(delayId, testRundown);
|
||||
expect(updatedRundown).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,131 @@
|
||||
import { EndAction, OntimeEvent, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
|
||||
|
||||
import { calculateRuntimeDelays, getDelayAt, calculateRuntimeDelaysFrom } from '../delayUtils.js';
|
||||
import { add, batchEdit, edit, remove, reorder, swap } from '../rundownCache.js';
|
||||
|
||||
describe('add() mutation', () => {
|
||||
test('adds an event to the rundown', () => {
|
||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||
const testRundown: OntimeRundown = [];
|
||||
const { newRundown } = add({ atIndex: 0, event: mockEvent, persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(1);
|
||||
expect(newRundown[0]).toMatchObject(mockEvent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove() mutation', () => {
|
||||
test('deletes an event from the rundown', () => {
|
||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||
const testRundown: OntimeRundown = [mockEvent];
|
||||
const { newRundown } = remove({ eventId: mockEvent.id, persistedRundown: testRundown });
|
||||
expect(newRundown.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit() mutation', () => {
|
||||
test('edits an event in the rundown', () => {
|
||||
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
|
||||
const mockEventPatch = { cue: 'patched' } as OntimeEvent;
|
||||
const testRundown: OntimeRundown = [mockEvent];
|
||||
const { newRundown, newEvent } = edit({
|
||||
eventId: mockEvent.id,
|
||||
patch: mockEventPatch,
|
||||
persistedRundown: testRundown,
|
||||
});
|
||||
expect(newRundown.length).toBe(1);
|
||||
expect(newEvent).toMatchObject({
|
||||
id: 'mock',
|
||||
cue: 'patched',
|
||||
type: SupportedEvent.Event,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchEdit() mutation', () => {
|
||||
it('should correctly apply the patch to the events with the given IDs', () => {
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
|
||||
];
|
||||
const eventIds = ['1', '3'];
|
||||
const patch = { cue: 'newData' };
|
||||
|
||||
const { newRundown } = batchEdit({ persistedRundown, eventIds, patch });
|
||||
|
||||
expect(newRundown).toMatchObject([
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'newData' },
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2' },
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'newData' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder() mutation', () => {
|
||||
it('should correctly reorder two events', () => {
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 0 } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
|
||||
];
|
||||
const { newRundown } = reorder({
|
||||
persistedRundown,
|
||||
eventId: persistedRundown[0].id,
|
||||
from: 0,
|
||||
to: persistedRundown.length - 1,
|
||||
});
|
||||
|
||||
expect(newRundown).toMatchObject([
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', revision: 1 },
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 1 },
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swap() mutation', () => {
|
||||
it('should correctly swap data between events', () => {
|
||||
const persistedRundown: OntimeRundown = [
|
||||
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent,
|
||||
{ id: '2', type: SupportedEvent.Event, cue: 'data2', timeStart: 2, revision: 0 } as OntimeEvent,
|
||||
{ id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
|
||||
];
|
||||
const { newRundown } = swap({
|
||||
persistedRundown,
|
||||
fromId: persistedRundown[0].id,
|
||||
toId: persistedRundown[1].id,
|
||||
});
|
||||
|
||||
expect((newRundown[0] as OntimeEvent).id).toBe('1');
|
||||
expect((newRundown[0] as OntimeEvent).cue).toBe('data2');
|
||||
expect((newRundown[0] as OntimeEvent).timeStart).toBe(1);
|
||||
expect((newRundown[0] as OntimeEvent).revision).toBe(1);
|
||||
|
||||
expect((newRundown[1] as OntimeEvent).id).toBe('2');
|
||||
expect((newRundown[1] as OntimeEvent).cue).toBe('data1');
|
||||
expect((newRundown[1] as OntimeEvent).timeStart).toBe(2);
|
||||
expect((newRundown[1] as OntimeEvent).revision).toBe(1);
|
||||
|
||||
expect((newRundown[2] as OntimeEvent).id).toBe('3');
|
||||
expect((newRundown[2] as OntimeEvent).cue).toBe('data3');
|
||||
expect((newRundown[2] as OntimeEvent).timeStart).toBe(3);
|
||||
expect((newRundown[2] as OntimeEvent).revision).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
describe('calculateRuntimeDelays', () => {
|
||||
it('calculates all delays in a given rundown', () => {
|
||||
@@ -38,7 +163,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
@@ -74,7 +198,6 @@ describe('calculateRuntimeDelays', () => {
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
@@ -190,7 +313,6 @@ describe('getDelayAt()', () => {
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
@@ -227,7 +349,6 @@ describe('getDelayAt()', () => {
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
@@ -362,7 +483,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
{
|
||||
duration: 600000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '07986',
|
||||
},
|
||||
{
|
||||
@@ -399,7 +519,6 @@ describe('calculateRuntimeDelaysFrom()', () => {
|
||||
{
|
||||
duration: 1200000,
|
||||
type: SupportedEvent.Delay,
|
||||
revision: 0,
|
||||
id: '7db42',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent } from 'ontime-types';
|
||||
|
||||
import { deleteAtIndex } from '../../utils/arrayUtils.js';
|
||||
import { deleteAtIndex } from '../../../../../packages/utils/src/array-utils/arrayUtils.js';
|
||||
|
||||
/**
|
||||
* Calculates all delays in a given rundown
|
||||
@@ -94,9 +94,10 @@ export function getDelayAt(eventIndex: number, rundown: OntimeRundown): number {
|
||||
* Applies delay from given event ID, deletes the delay event after
|
||||
* @param eventId
|
||||
* @param rundown
|
||||
* @throws {Error} if event ID not found or is not a delay
|
||||
* @returns
|
||||
*/
|
||||
export function applyDelay(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
||||
export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
|
||||
const delayIndex = rundown.findIndex((event) => event.id === eventId);
|
||||
const delayEvent = rundown.at(delayIndex);
|
||||
|
||||
|
||||
@@ -1,288 +1,260 @@
|
||||
import {
|
||||
GetRundownCached,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OntimeRundownEntry,
|
||||
} from 'ontime-types';
|
||||
import { swapOntimeEvents } from 'ontime-utils';
|
||||
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData } from 'ontime-utils';
|
||||
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { getCached, runtimeCacheStore } from '../../stores/cachingStore.js';
|
||||
import { isProduction } from '../../setup.js';
|
||||
import { deleteAtIndex, insertAtIndex, reorderArray } from '../../utils/arrayUtils.js';
|
||||
import { createPatch } from '../../utils/parser.js';
|
||||
import { applyDelay, calculateRuntimeDelays, calculateRuntimeDelaysFromIndex, getDelayAt } from './delayUtils.js';
|
||||
import { apply } from './delayUtils.js';
|
||||
|
||||
type NormalisedRundown = Record<string, OntimeRundownEntry>;
|
||||
|
||||
let rundown: NormalisedRundown = {};
|
||||
let order: string[] = [];
|
||||
let revision = 0;
|
||||
let isStale = true;
|
||||
|
||||
/**
|
||||
* Keep incremental revision number of rundown for runtime
|
||||
* Utility initialises cache
|
||||
* @param persistedRundown
|
||||
*/
|
||||
let rundownRevision = 0;
|
||||
export function init(persistedRundown: Readonly<OntimeRundown>) {
|
||||
// we decided to try and re-write this dataset for every change
|
||||
// instead of maintaining logic to update it
|
||||
rundown = {};
|
||||
order = [];
|
||||
|
||||
/**
|
||||
* Key of rundown in cache
|
||||
*/
|
||||
export const delayedRundownCacheKey = 'delayed-rundown';
|
||||
let accumulatedDelay = 0;
|
||||
for (let i = 0; i < persistedRundown.length; i++) {
|
||||
const event = persistedRundown[i];
|
||||
|
||||
/**
|
||||
* Invalidates the cached rundown when an inconsistency is found
|
||||
* will throw when not in production
|
||||
* @param errorMessage
|
||||
*/
|
||||
export function invalidateFromError(errorMessage = 'Found mismatch between store and cache') {
|
||||
if (isProduction) {
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
} else {
|
||||
throw new Error(errorMessage);
|
||||
// calculate delays
|
||||
if (isOntimeDelay(event)) {
|
||||
accumulatedDelay += event.duration;
|
||||
} else if (isOntimeBlock(event)) {
|
||||
accumulatedDelay = 0;
|
||||
} else if (isOntimeEvent(event)) {
|
||||
event.delay = accumulatedDelay;
|
||||
}
|
||||
|
||||
order.push(event.id);
|
||||
rundown[event.id] = { ...event };
|
||||
}
|
||||
isStale = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
* Returns an ID guaranteed to be unique
|
||||
* @returns
|
||||
*/
|
||||
export function getRundownCache(): GetRundownCached {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
export function getUniqueId(persistedRundown: Readonly<OntimeRundown> = getPersistedRundown()): string {
|
||||
let id = '';
|
||||
do {
|
||||
id = generateId();
|
||||
} while (!isIdUnique(persistedRundown, id));
|
||||
return id;
|
||||
}
|
||||
|
||||
export function isIdUnique(persistedRundown: Readonly<OntimeRundown>, eventId: string) {
|
||||
if (isStale) {
|
||||
init(persistedRundown);
|
||||
}
|
||||
return !Object.hasOwn(rundown, eventId);
|
||||
}
|
||||
|
||||
const cached = getCached(delayedRundownCacheKey, calculateRundown);
|
||||
export function getIndexOf(eventId: string) {
|
||||
if (isStale) {
|
||||
init(getPersistedRundown());
|
||||
}
|
||||
return order.indexOf(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function gets rundown from DataProvider
|
||||
* @returns {OntimeRundown}
|
||||
*/
|
||||
export const getPersistedRundown = (): OntimeRundown => DataProvider.getRundown();
|
||||
|
||||
type RundownCache = {
|
||||
rundown: NormalisedRundown;
|
||||
order: string[];
|
||||
revision: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns cached data
|
||||
* @returns {RundownCache}
|
||||
*/
|
||||
export function get(): Readonly<RundownCache> {
|
||||
if (isStale) {
|
||||
console.time('rundownCache__init');
|
||||
init(getPersistedRundown());
|
||||
console.timeEnd('rundownCache__init');
|
||||
}
|
||||
return {
|
||||
rundown: cached,
|
||||
revision: rundownRevision,
|
||||
rundown,
|
||||
order,
|
||||
revision,
|
||||
};
|
||||
}
|
||||
|
||||
type CommonParams = { persistedRundown: OntimeRundown };
|
||||
type MutationParams<T> = T & Partial<CommonParams>;
|
||||
type MutatingReturn = {
|
||||
newRundown: OntimeRundown;
|
||||
newEvent?: OntimeRundownEntry;
|
||||
};
|
||||
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
|
||||
/**
|
||||
* Returns rundown with calculated delays
|
||||
* Ensures request goes through the caching layer
|
||||
* Decorators injects data into mutation
|
||||
* @param mutation
|
||||
* @returns
|
||||
*/
|
||||
export function getDelayedRundown() {
|
||||
function calculateRundown() {
|
||||
const rundown = DataProvider.getRundown();
|
||||
return calculateRuntimeDelays(rundown);
|
||||
}
|
||||
export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
|
||||
async function scopedMutation(params: T) {
|
||||
const persistedRundown = getPersistedRundown();
|
||||
const { newEvent, newRundown } = mutation({ ...params, persistedRundown });
|
||||
|
||||
return getCached(delayedRundownCacheKey, calculateRundown);
|
||||
revision = revision + 1;
|
||||
isStale = true;
|
||||
|
||||
DataProvider.setRundown(newRundown);
|
||||
// schedule the update to the next tick
|
||||
|
||||
process.nextTick(() => {
|
||||
console.time('rundownCache__init');
|
||||
init(newRundown);
|
||||
console.timeEnd('rundownCache__init');
|
||||
});
|
||||
|
||||
// TODO: could we return a patch object?
|
||||
return { newEvent };
|
||||
}
|
||||
return scopedMutation;
|
||||
}
|
||||
|
||||
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
|
||||
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
|
||||
const newEvent: OntimeRundownEntry = { ...event };
|
||||
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown);
|
||||
|
||||
return { newRundown, newEvent };
|
||||
}
|
||||
|
||||
type RemoveArgs = MutationParams<{ eventId: string }>;
|
||||
export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn {
|
||||
const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
|
||||
const newRundown = deleteAtIndex(atIndex, persistedRundown);
|
||||
|
||||
return { newRundown };
|
||||
}
|
||||
|
||||
export function removeAll(): { newRundown: OntimeRundown } {
|
||||
return { newRundown: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an event in the rundown at given index, ensuring replication to delayed rundown cache
|
||||
* @param eventIndex
|
||||
* @param event
|
||||
* Utility function for patching events
|
||||
* @param eventFromRundown
|
||||
* @param patch
|
||||
* @returns
|
||||
*/
|
||||
export async function cachedAdd(eventIndex: number, event: OntimeEvent | OntimeDelay | OntimeBlock) {
|
||||
// TODO: create wrapper function
|
||||
const rundown = DataProvider.getRundown();
|
||||
const newRundown = insertAtIndex(eventIndex, event, rundown);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
let newDelayedRundown = insertAtIndex(eventIndex, event, delayedRundown);
|
||||
|
||||
// update delay cache
|
||||
if (isOntimeEvent(event)) {
|
||||
// if it is an event, we need its delay
|
||||
(newDelayedRundown[eventIndex] as OntimeEvent).delay = getDelayAt(eventIndex, newDelayedRundown);
|
||||
} else {
|
||||
// if it is a block or delay, we invalidate from here
|
||||
newDelayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, newDelayedRundown);
|
||||
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
|
||||
if (isOntimeEvent(eventFromRundown)) {
|
||||
const newEvent = createPatch(eventFromRundown, patch as OntimeEvent);
|
||||
newEvent.revision++;
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
// we need to delay updating this to ensure add operation happens on same dataset
|
||||
await DataProvider.setRundown(newRundown);
|
||||
|
||||
rundownRevision++;
|
||||
// TODO: exhaustive check
|
||||
return { ...eventFromRundown, ...patch } as OntimeRundownEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an event in rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
* @param patchObject
|
||||
*/
|
||||
export async function cachedEdit(
|
||||
eventId: string,
|
||||
patchObject: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>,
|
||||
) {
|
||||
const makeEvent = (eventFromRundown: OntimeRundownEntry): OntimeRundownEntry => {
|
||||
if (isOntimeEvent(eventFromRundown)) {
|
||||
const newEvent = createPatch(eventFromRundown, patchObject as OntimeEvent);
|
||||
newEvent.revision++;
|
||||
return newEvent;
|
||||
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
|
||||
export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
|
||||
const indexAt = persistedRundown.findIndex((event) => event.id === eventId);
|
||||
|
||||
if (indexAt < 0) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
if (patch?.type && persistedRundown[indexAt].type !== patch.type) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
|
||||
const eventInMemory = persistedRundown[indexAt];
|
||||
const newEvent = makeEvent(eventInMemory, patch);
|
||||
const newRundown = [...persistedRundown];
|
||||
newRundown[indexAt] = newEvent;
|
||||
|
||||
return { newRundown, newEvent };
|
||||
}
|
||||
|
||||
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
|
||||
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
|
||||
const ids = new Set(eventIds);
|
||||
|
||||
const newRundown = [];
|
||||
for (let i = 0; i < persistedRundown.length; i++) {
|
||||
if (ids.has(persistedRundown[i].id)) {
|
||||
if (patch?.type && persistedRundown[i].type !== patch.type) {
|
||||
continue;
|
||||
}
|
||||
const newEvent = makeEvent(persistedRundown[i], patch);
|
||||
newRundown.push(newEvent);
|
||||
} else {
|
||||
newRundown.push(persistedRundown[i]);
|
||||
}
|
||||
}
|
||||
return { newRundown };
|
||||
}
|
||||
|
||||
return { ...eventFromRundown, ...patchObject } as OntimeRundownEntry;
|
||||
};
|
||||
|
||||
const indexInMemory = DataProvider.getIndexOf(eventId);
|
||||
if (indexInMemory < 0) {
|
||||
throw new Error('No event with ID found');
|
||||
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
|
||||
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
|
||||
const event = persistedRundown[from];
|
||||
if (!event || eventId !== event.id) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const updatedRundown = DataProvider.getRundown();
|
||||
const eventFromRundown = updatedRundown[indexInMemory];
|
||||
|
||||
const isPatchObjectDifferentFromRundownEvent = Object.entries(patchObject).some(
|
||||
([key, value]) => eventFromRundown[key] !== value,
|
||||
);
|
||||
|
||||
if (!isPatchObjectDifferentFromRundownEvent) {
|
||||
return eventFromRundown;
|
||||
}
|
||||
|
||||
const newEvent = makeEvent(eventFromRundown);
|
||||
updatedRundown[indexInMemory] = newEvent;
|
||||
|
||||
let newDelayedRundown = getDelayedRundown();
|
||||
if (newDelayedRundown?.[indexInMemory].id !== newEvent.id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
newDelayedRundown[indexInMemory] = newEvent;
|
||||
if (isOntimeEvent(newEvent)) {
|
||||
(newDelayedRundown[indexInMemory] as OntimeEvent).delay = getDelayAt(indexInMemory, newDelayedRundown);
|
||||
} else if (isOntimeDelay(newEvent)) {
|
||||
// blocks have no reason to change the rundown, from delays we need to recalculate
|
||||
newDelayedRundown = calculateRuntimeDelaysFromIndex(indexInMemory, newDelayedRundown);
|
||||
const newRundown = reorderArray(persistedRundown, from, to);
|
||||
for (let i = from; i <= to; i++) {
|
||||
const event = newRundown.at(i);
|
||||
if (isOntimeEvent(event)) {
|
||||
event.revision += 1;
|
||||
}
|
||||
}
|
||||
return { newRundown, newEvent: newRundown.at(from) };
|
||||
}
|
||||
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, newDelayedRundown);
|
||||
type ApplyDelayArgs = MutationParams<{ eventId: string }>;
|
||||
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn {
|
||||
const newRundown = apply(eventId, persistedRundown);
|
||||
return { newRundown };
|
||||
}
|
||||
|
||||
type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
|
||||
export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn {
|
||||
const indexA = persistedRundown.findIndex((event) => event.id === fromId);
|
||||
const eventA = persistedRundown.at(indexA);
|
||||
|
||||
const indexB = persistedRundown.findIndex((event) => event.id === toId);
|
||||
const eventB = persistedRundown.at(indexB);
|
||||
|
||||
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
|
||||
throw new Error('Swap only available for OntimeEvents');
|
||||
}
|
||||
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
const { newA, newB } = swapEventData(eventA, eventB);
|
||||
const newRundown = [...persistedRundown];
|
||||
|
||||
rundownRevision++;
|
||||
newRundown[indexA] = newA;
|
||||
(newRundown[indexA] as OntimeEvent).revision += 1;
|
||||
newRundown[indexB] = newB;
|
||||
(newRundown[indexB] as OntimeEvent).revision += 1;
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
export async function cachedBatchEdit(ids: string[], patchObject: Partial<OntimeEvent>) {
|
||||
const cachedEdits = ids.map((id) => cachedEdit(id, patchObject));
|
||||
|
||||
await Promise.allSettled(cachedEdits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an event with given id from rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
*/
|
||||
export async function cachedDelete(eventId: string) {
|
||||
const eventIndex = DataProvider.getIndexOf(eventId);
|
||||
let delayedRundown = getDelayedRundown();
|
||||
|
||||
if (eventIndex < 0) {
|
||||
if (delayedRundown.findIndex((event) => event.id === eventId) >= 0) {
|
||||
invalidateFromError();
|
||||
}
|
||||
throw new Error(`Event with id ${eventId} not found`);
|
||||
}
|
||||
|
||||
let updatedRundown = DataProvider.getRundown();
|
||||
const eventBack = { ...updatedRundown[eventIndex] };
|
||||
updatedRundown = deleteAtIndex(eventIndex, updatedRundown);
|
||||
if (eventId !== delayedRundown[eventIndex].id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
delayedRundown = deleteAtIndex(eventIndex, delayedRundown);
|
||||
if (isOntimeDelay(eventBack) || isOntimeBlock(eventBack)) {
|
||||
// for events, we do not have to worry
|
||||
// the following event, would have taken the place of the deleted event by now
|
||||
delayedRundown = calculateRuntimeDelaysFromIndex(eventIndex, delayedRundown);
|
||||
}
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundown);
|
||||
}
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders an event in the rundown, ensuring replication to delayed rundown cache
|
||||
* @param eventId
|
||||
* @param from
|
||||
* @param to
|
||||
*/
|
||||
export async function cachedReorder(eventId: string, from: number, to: number) {
|
||||
const indexCheck = DataProvider.getIndexOf(eventId);
|
||||
if (indexCheck !== from) {
|
||||
invalidateFromError();
|
||||
throw new Error('ID not found at index');
|
||||
}
|
||||
|
||||
let updatedRundown = DataProvider.getRundown();
|
||||
const reorderedEvent = updatedRundown[from];
|
||||
updatedRundown = reorderArray(updatedRundown, from, to);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
if (eventId !== delayedRundown[from].id) {
|
||||
invalidateFromError();
|
||||
} else {
|
||||
// TODO: could we be more granular about updates
|
||||
// I fear we need to update both from and to, which could signify more iterations
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
}
|
||||
|
||||
// we need to delay updating this to ensure edit operation happens on same dataset
|
||||
await DataProvider.setRundown(updatedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
|
||||
return reorderedEvent;
|
||||
}
|
||||
|
||||
export async function cachedClear() {
|
||||
await DataProvider.clearRundown();
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, []);
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps two events
|
||||
* @param {string} fromEventId
|
||||
* @param {string} toEventId
|
||||
*/
|
||||
export async function cachedSwap(fromEventId: string, toEventId: string) {
|
||||
const fromEventIndex = DataProvider.getIndexOf(fromEventId);
|
||||
const toEventIndex = DataProvider.getIndexOf(toEventId);
|
||||
|
||||
const rundown = DataProvider.getRundown();
|
||||
const rundownToUpdate = swapOntimeEvents(rundown, fromEventIndex, toEventIndex);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
const fromCachedEvent = delayedRundown.at(fromEventIndex);
|
||||
const toCachedEvent = delayedRundown.at(toEventIndex);
|
||||
|
||||
if (fromCachedEvent.id !== fromEventId || toCachedEvent.id !== toEventId) {
|
||||
// something went wrong, we invalidate the cache
|
||||
runtimeCacheStore.invalidate(delayedRundownCacheKey);
|
||||
} else {
|
||||
const delayedRundownToUpdate = swapOntimeEvents(delayedRundown, fromEventIndex, toEventIndex);
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, delayedRundownToUpdate);
|
||||
}
|
||||
|
||||
await DataProvider.setRundown(rundownToUpdate);
|
||||
|
||||
rundownRevision++;
|
||||
}
|
||||
|
||||
export async function cachedApplyDelay(eventId: string) {
|
||||
// update persisted rundown
|
||||
const rundown: OntimeRundown = DataProvider.getRundown();
|
||||
const persistedRundown = applyDelay(eventId, rundown);
|
||||
|
||||
const delayedRundown = getDelayedRundown();
|
||||
const cachedRundown = applyDelay(eventId, delayedRundown);
|
||||
|
||||
// update
|
||||
runtimeCacheStore.setCached(delayedRundownCacheKey, cachedRundown);
|
||||
await DataProvider.setRundown(persistedRundown);
|
||||
|
||||
rundownRevision++;
|
||||
return { newRundown };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { TimerService } from '../TimerService.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { RestorePoint } from '../RestoreService.js';
|
||||
import * as runtimeState from '../../stores/runtimeState.js';
|
||||
import { findNext, findPrevious, getEventAtIndex, getEventWithCue, getEventWithId, getPlayableEvents } from '../rundown-service/RundownService.js';
|
||||
import {
|
||||
findNext,
|
||||
findPrevious,
|
||||
getEventAtIndex,
|
||||
getEventWithCue,
|
||||
getEventWithId,
|
||||
getPlayableEvents,
|
||||
} from '../rundown-service/RundownService.js';
|
||||
|
||||
/**
|
||||
* Service manages runtime status of app
|
||||
@@ -142,7 +149,7 @@ class RuntimeService {
|
||||
|
||||
const timedEvents = getPlayableEvents();
|
||||
const state = runtimeState.getState();
|
||||
// TODO: return success boolean from runtimeState
|
||||
// TODO: return success boolean from runtimeState, when we work with optimising integrations
|
||||
runtimeState.load(event, timedEvents);
|
||||
const success = event.id === state.eventNow?.id;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { dayInMs, sortArrayByProperty } from 'ontime-utils';
|
||||
import { RuntimeState } from '../stores/runtimeState.js';
|
||||
import { sortArrayByProperty } from '../utils/arrayUtils.js';
|
||||
|
||||
/**
|
||||
* handle events that span over midnight
|
||||
|
||||
@@ -39,8 +39,8 @@ const env = process.env.NODE_ENV || 'production';
|
||||
|
||||
export const isTest = Boolean(process.env.IS_TEST);
|
||||
export const environment = isTest ? 'test' : env;
|
||||
export const isProduction = env === ('production' || 'docker') && !isTest;
|
||||
export const isDocker = env === 'docker';
|
||||
export const isProduction = isDocker || (env === 'production' && !isTest);
|
||||
|
||||
// =================================================
|
||||
// resolve path to external
|
||||
@@ -71,7 +71,7 @@ export const currentDirectory = dirname(__dirname);
|
||||
|
||||
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
|
||||
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
|
||||
//TODO: we only need one when they are all in the same folder
|
||||
// TODO: we only need one when they are all in the same folder
|
||||
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external');
|
||||
|
||||
// project files
|
||||
@@ -89,13 +89,14 @@ const getLastLoadedProject = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const lastLoadedProject = getLastLoadedProject();
|
||||
const lastLoadedProject = isTest ? 'db.json' : getLastLoadedProject();
|
||||
|
||||
// path to public db
|
||||
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? config.database.testdb : 'uploads');
|
||||
export const resolveDbDirectory = join(testDbStartDirectory, isTest ? `../${config.database.testdb}` : 'uploads');
|
||||
export const resolveDbPath = join(resolveDbDirectory, lastLoadedProject ? lastLoadedProject : config.database.filename);
|
||||
|
||||
export const pathToStartDb = isTest
|
||||
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
|
||||
? join(currentDirectory, '..', config.database.testdb, config.database.filename)
|
||||
: join(currentDirectory, '/preloaded-db/', config.database.filename);
|
||||
|
||||
// TODO: move all static files to the external directory
|
||||
@@ -108,7 +109,7 @@ export const pathToStartStyles = join(currentDirectory, '/external/styles/', con
|
||||
// path to public demo
|
||||
export const resolveDemoDirectory = join(
|
||||
externalsStartDirectory,
|
||||
isProduction ? '/external/' : '', //move to external folde in production
|
||||
isProduction ? '/external/' : '', // move to external folder in production
|
||||
config.demo.directory,
|
||||
);
|
||||
export const resolveDemoPath = config.demo.filename.map((file) => {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { runtimeCacheStore } from '../cachingStore.js';
|
||||
|
||||
describe('cachingStore()', () => {
|
||||
beforeEach(() => {
|
||||
runtimeCacheStore.clear(); // Clear the cache before each test
|
||||
});
|
||||
|
||||
it('should check if an item is cached', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Check if the item is cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(true);
|
||||
expect(runtimeCacheStore.checkCached('non-existent-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get an item from the cache', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Get the item from the cache
|
||||
const result = runtimeCacheStore.getCached('key', () => 'default-value');
|
||||
|
||||
// Check the returned value
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
|
||||
it('should retrieve default value when item is not cached', () => {
|
||||
// Get an item that is not in the cache
|
||||
const result = runtimeCacheStore.getCached('non-existent-key', () => 'default-value');
|
||||
|
||||
// Check the returned value
|
||||
expect(result).toBe('default-value');
|
||||
});
|
||||
|
||||
it('should set an item in the cache', () => {
|
||||
// Set an item in the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Check if the item is cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(true);
|
||||
});
|
||||
|
||||
it('should invalidate an item in the cache', () => {
|
||||
// Add an item to the cache
|
||||
runtimeCacheStore.setCached('key', 'value');
|
||||
|
||||
// Invalidate the item
|
||||
runtimeCacheStore.invalidate('key');
|
||||
|
||||
// Check if the item is no longer cached
|
||||
expect(runtimeCacheStore.checkCached('key')).toBe(false);
|
||||
});
|
||||
|
||||
it('should clear the cache', () => {
|
||||
// Add items to the cache
|
||||
runtimeCacheStore.setCached('key1', 'value1');
|
||||
runtimeCacheStore.setCached('key2', 'value2');
|
||||
|
||||
// Clear the cache
|
||||
runtimeCacheStore.clear();
|
||||
|
||||
// Check if the cache is empty
|
||||
expect(runtimeCacheStore.checkCached('key1')).toBe(false);
|
||||
expect(runtimeCacheStore.checkCached('key2')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
interface CacheData {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
const runtimeCache: Map<string, CacheData> = new Map();
|
||||
|
||||
export function checkCached(key: string): boolean {
|
||||
return runtimeCache.has(key);
|
||||
}
|
||||
|
||||
export function getCached<T>(key: string, callback: () => T): T {
|
||||
if (!runtimeCache.has(key)) {
|
||||
try {
|
||||
const data = callback();
|
||||
runtimeCache.set(key, { data });
|
||||
} catch (error) {
|
||||
console.error(`Failed retrieving data from callback: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return runtimeCache.get(key).data as T;
|
||||
}
|
||||
|
||||
export function setCached<T>(key: string, value: T): T {
|
||||
runtimeCache.set(key, { data: value });
|
||||
return value;
|
||||
}
|
||||
|
||||
export function invalidate(key: string) {
|
||||
runtimeCache.delete(key);
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
runtimeCache.clear();
|
||||
}
|
||||
|
||||
function createCacheStore() {
|
||||
return {
|
||||
checkCached,
|
||||
getCached,
|
||||
setCached,
|
||||
invalidate,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
|
||||
export const runtimeCacheStore = createCacheStore();
|
||||
@@ -1,88 +0,0 @@
|
||||
import { insertAtIndex, reorderArray, sortArrayByProperty } from '../arrayUtils.js';
|
||||
|
||||
describe('insertAtIndex', () => {
|
||||
it('should insert an item at the beginning of the array', () => {
|
||||
const array = [2, 3, 4];
|
||||
const result = insertAtIndex(0, 1, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should insert an item at the end of the array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const result = insertAtIndex(3, 4, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should insert an item in the middle of the array', () => {
|
||||
const array = [1, 2, 4];
|
||||
const result = insertAtIndex(2, 3, array);
|
||||
expect(result).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('should return a new array and not modify the original array', () => {
|
||||
const array = [1, 2, 3];
|
||||
const result = insertAtIndex(1, 5, array);
|
||||
expect(result).toEqual([1, 5, 2, 3]);
|
||||
expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderArray', () => {
|
||||
it('should reorder an item in the array', () => {
|
||||
const array = ['a', 'b', 'c', 'd'];
|
||||
const result = reorderArray(array, 1, 3);
|
||||
expect(result).toEqual(['a', 'c', 'd', 'b']);
|
||||
});
|
||||
|
||||
it('should return the original array if fromIndex and toIndex are the same', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 1, 1);
|
||||
expect(result).toEqual(array);
|
||||
});
|
||||
|
||||
it('should handle reordering to the beginning of the array', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 2, 0);
|
||||
expect(result).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('should handle reordering to the end of the array', () => {
|
||||
const array = ['a', 'b', 'c'];
|
||||
const result = reorderArray(array, 0, 2);
|
||||
expect(result).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortArrayByProperty()', () => {
|
||||
it('sort array 1-5', () => {
|
||||
const arr1 = [{ timeStart: 1 }, { timeStart: 5 }, { timeStart: 3 }, { timeStart: 2 }, { timeStart: 4 }];
|
||||
|
||||
const arr1Expected = [{ timeStart: 1 }, { timeStart: 2 }, { timeStart: 3 }, { timeStart: 4 }, { timeStart: 5 }];
|
||||
|
||||
const sorted = sortArrayByProperty(arr1, 'timeStart');
|
||||
expect(sorted).toStrictEqual(arr1Expected);
|
||||
});
|
||||
|
||||
it('sort array 1-5 with null', () => {
|
||||
const arr1 = [
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 5 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 4 },
|
||||
{ timeStart: null },
|
||||
];
|
||||
|
||||
const arr1Expected = [
|
||||
{ timeStart: null },
|
||||
{ timeStart: 1 },
|
||||
{ timeStart: 2 },
|
||||
{ timeStart: 3 },
|
||||
{ timeStart: 4 },
|
||||
{ timeStart: 5 },
|
||||
];
|
||||
|
||||
const sorted = sortArrayByProperty(arr1, 'timeStart');
|
||||
expect(sorted).toStrictEqual(arr1Expected);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Inserts an item in an array at a given index
|
||||
* @param index
|
||||
* @param item
|
||||
* @param array
|
||||
*/
|
||||
export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
|
||||
const modifiedArray = [...array];
|
||||
|
||||
// Insert at beginning
|
||||
if (index === 0) {
|
||||
modifiedArray.unshift(item);
|
||||
}
|
||||
|
||||
// insert at end
|
||||
else if (index >= modifiedArray.length) {
|
||||
modifiedArray.push(item);
|
||||
}
|
||||
|
||||
// insert in the middle
|
||||
else {
|
||||
modifiedArray.splice(index, 0, item);
|
||||
}
|
||||
|
||||
return modifiedArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes array element at a given index
|
||||
* @param index
|
||||
* @param array
|
||||
*/
|
||||
export function deleteAtIndex<T>(index: number, array: T[]) {
|
||||
return array.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number) {
|
||||
if (fromIndex === toIndex) {
|
||||
return array; // No change needed, return the original array
|
||||
}
|
||||
|
||||
const modifiedArray = [...array];
|
||||
|
||||
// delete in from
|
||||
const [reorderedItem] = modifiedArray.splice(fromIndex, 1);
|
||||
|
||||
// reinsert item at to
|
||||
modifiedArray.splice(toIndex, 0, reorderedItem);
|
||||
return modifiedArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Sorts an array of objects by given property
|
||||
* @param {array} arr - array to be sorted
|
||||
* @param {string} property - property to compare
|
||||
* @returns {array} copy of array sorted in ascending order
|
||||
*/
|
||||
|
||||
export const sortArrayByProperty = <T>(arr: T[], property: string): T[] => {
|
||||
return [...arr].sort((a, b) => {
|
||||
return a[property] - b[property];
|
||||
});
|
||||
};
|
||||
@@ -371,7 +371,8 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
|
||||
user8: makeString(patchEvent.user8, originalEvent.user8),
|
||||
user9: makeString(patchEvent.user9, originalEvent.user9),
|
||||
colour: makeString(patchEvent.colour, originalEvent.colour),
|
||||
cue: makeString(patchEvent.cue, originalEvent.cue),
|
||||
// short circuit empty string
|
||||
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
||||
revision: originalEvent.revision,
|
||||
timeWarning: patchEvent.timeWarning,
|
||||
timeDanger: patchEvent.timeDanger,
|
||||
|
||||
Reference in New Issue
Block a user