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:
Carlos Valente
2024-02-03 21:43:17 +01:00
committed by GitHub
parent 47a519bac1
commit 1890fc49d7
62 changed files with 1838 additions and 3341 deletions
@@ -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`);
}
+10 -11
View File
@@ -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'> = {
+1
View File
@@ -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 -2
View File
@@ -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
+7 -6
View File
@@ -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);
});
});
-47
View File
@@ -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);
});
});
-63
View File
@@ -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];
});
};
+2 -1
View File
@@ -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,