convert to and from database type (#1410)

* create db event type

* dont use it yet

* convert to and from database type

* type needed in one place in client

* use flatMap to filter out unknow stuff

* leave revision in save file

* move to utils

* make test

* destructur entry

* simple test

* throw in rundownToDatabaseRundown

* clerify comment

* Refactor: avoid shadowing the rundown variable in rundownCache (#1409)


---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* rename to DAO

* add simple edit test

* only the generate function clears the state state

* strict guard for custom fields

* fix small strict issues

* ensure deep copy

* safely set isStale

* todo is done

* small type error

* these functions can work on DAO entries

* only reat rundown as DAO on initial load and the cast immediately and esure all fields are present

* eslint comment

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* fix spelling

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

* comment not needed

* setIsStale

* remove DAO

* clean up commits

* remove unneeded async/await

* revert test

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
Alex Christoffer Rasmussen
2025-01-05 14:35:58 +01:00
committed by GitHub
parent 1b62ed5f2d
commit 533f9798e8
12 changed files with 195 additions and 130 deletions
@@ -24,6 +24,7 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
skip: event.skip, skip: event.skip,
colour: event.colour, colour: event.colour,
revision: 0, revision: 0,
delay: 0,
timeWarning: event.timeWarning, timeWarning: event.timeWarning,
timeDanger: event.timeDanger, timeDanger: event.timeDanger,
custom: {}, custom: {},
@@ -86,7 +86,7 @@ function getCustomFields(): Readonly<CustomFields> {
} }
async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> { async function setRundown(newData: OntimeRundown): ReadonlyPromise<OntimeRundown> {
db.data.rundown = [...newData]; db.data.rundown = newData;
await persist(); await persist();
return db.data.rundown; return db.data.rundown;
} }
@@ -143,6 +143,7 @@ describe('safeMerge', () => {
publicInfo: '', publicInfo: '',
backstageUrl: '', backstageUrl: '',
backstageInfo: '', backstageInfo: '',
projectLogo: null,
}, },
settings: { settings: {
app: 'ontime', app: 'ontime',
+14
View File
@@ -20,6 +20,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -45,6 +46,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -70,6 +72,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -95,6 +98,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -120,6 +124,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -150,6 +155,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -175,6 +181,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -200,6 +207,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -225,6 +233,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -250,6 +259,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -280,6 +290,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -305,6 +316,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -330,6 +342,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
@@ -355,6 +368,7 @@ export const demoDb: DatabaseModel = {
skip: false, skip: false,
colour: '', colour: '',
revision: 0, revision: 0,
delay: 0,
timeWarning: 500000, timeWarning: 500000,
timeDanger: 100000, timeDanger: 100000,
custom: { custom: {
+2 -1
View File
@@ -8,7 +8,7 @@ import {
TimerType, TimerType,
} from 'ontime-types'; } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = { export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
title: '', title: '',
note: '', note: '',
endAction: EndAction.None, endAction: EndAction.None,
@@ -24,6 +24,7 @@ export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
colour: '', colour: '',
type: SupportedEvent.Event, type: SupportedEvent.Event,
revision: 0, revision: 0,
delay: 0,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
custom: {}, custom: {},
@@ -438,7 +438,7 @@ describe('add() mutation', () => {
test('adds an event to the rundown', () => { test('adds an event to the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = []; const testRundown: OntimeRundown = [];
const { newRundown } = add({ atIndex: 0, event: mockEvent, persistedRundown: testRundown }); const { newRundown } = add({ atIndex: 0, event: mockEvent, rundown: testRundown });
expect(newRundown.length).toBe(1); expect(newRundown.length).toBe(1);
expect(newRundown[0]).toMatchObject(mockEvent); expect(newRundown[0]).toMatchObject(mockEvent);
}); });
@@ -448,7 +448,7 @@ describe('remove() mutation', () => {
test('deletes an event from the rundown', () => { test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent; const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent]; const testRundown: OntimeRundown = [mockEvent];
const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown }); const { newRundown } = remove({ eventIds: [mockEvent.id], rundown: testRundown });
expect(newRundown.length).toBe(0); expect(newRundown.length).toBe(0);
}); });
test('deletes multiple events from the rundown', () => { test('deletes multiple events from the rundown', () => {
@@ -460,7 +460,7 @@ describe('remove() mutation', () => {
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent, { type: SupportedEvent.Event, id: '5' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent, { type: SupportedEvent.Event, id: '6' } as OntimeEvent,
]; ];
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown }); const { newRundown } = remove({ eventIds: ['1', '2', '3'], rundown: testRundown });
expect(newRundown.length).toBe(3); expect(newRundown.length).toBe(3);
expect(newRundown.at(0)?.id).toBe('4'); expect(newRundown.at(0)?.id).toBe('4');
}); });
@@ -474,7 +474,7 @@ describe('edit() mutation', () => {
const { newRundown, newEvent } = edit({ const { newRundown, newEvent } = edit({
eventId: mockEvent.id, eventId: mockEvent.id,
patch: mockEventPatch, patch: mockEventPatch,
persistedRundown: testRundown, rundown: testRundown,
}); });
expect(newRundown.length).toBe(1); expect(newRundown.length).toBe(1);
expect(newEvent).toMatchObject({ expect(newEvent).toMatchObject({
@@ -487,7 +487,7 @@ describe('edit() mutation', () => {
describe('batchEdit() mutation', () => { describe('batchEdit() mutation', () => {
it('should correctly apply the patch to the events with the given IDs', () => { it('should correctly apply the patch to the events with the given IDs', () => {
const persistedRundown: OntimeRundown = [ const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent, { id: '1', type: SupportedEvent.Event, cue: 'data1' } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent, { id: '2', type: SupportedEvent.Event, cue: 'data2' } as OntimeEvent,
{ id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent, { id: '3', type: SupportedEvent.Event, cue: 'data3' } as OntimeEvent,
@@ -495,7 +495,7 @@ describe('batchEdit() mutation', () => {
const eventIds = ['1', '3']; const eventIds = ['1', '3'];
const patch = { cue: 'newData' }; const patch = { cue: 'newData' };
const { newRundown } = batchEdit({ persistedRundown, eventIds, patch }); const { newRundown } = batchEdit({ rundown: testRundown, eventIds, patch });
expect(newRundown).toMatchObject([ expect(newRundown).toMatchObject([
{ id: '1', type: SupportedEvent.Event, cue: 'newData' }, { id: '1', type: SupportedEvent.Event, cue: 'newData' },
@@ -507,16 +507,16 @@ describe('batchEdit() mutation', () => {
describe('reorder() mutation', () => { describe('reorder() mutation', () => {
it('should correctly reorder two events', () => { it('should correctly reorder two events', () => {
const persistedRundown: OntimeRundown = [ const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent, { id: '1', type: SupportedEvent.Event, cue: 'data1', revision: 0 } as OntimeEvent,
{ id: '2', type: SupportedEvent.Event, cue: 'data2', 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, { id: '3', type: SupportedEvent.Event, cue: 'data3', revision: 0 } as OntimeEvent,
]; ];
const { newRundown } = reorder({ const { newRundown } = reorder({
persistedRundown, rundown: testRundown,
eventId: persistedRundown[0].id, eventId: testRundown[0].id,
from: 0, from: 0,
to: persistedRundown.length - 1, to: testRundown.length - 1,
}); });
expect(newRundown).toMatchObject([ expect(newRundown).toMatchObject([
@@ -529,15 +529,15 @@ describe('reorder() mutation', () => {
describe('swap() mutation', () => { describe('swap() mutation', () => {
it('should correctly swap data between events', () => { it('should correctly swap data between events', () => {
const persistedRundown: OntimeRundown = [ const testRundown: OntimeRundown = [
{ id: '1', type: SupportedEvent.Event, cue: 'data1', timeStart: 1, revision: 0 } as OntimeEvent, { 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: '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, { id: '3', type: SupportedEvent.Event, cue: 'data3', timeStart: 3, revision: 0 } as OntimeEvent,
]; ];
const { newRundown } = swap({ const { newRundown } = swap({
persistedRundown, rundown: testRundown,
fromId: persistedRundown[0].id, fromId: testRundown[0].id,
toId: persistedRundown[1].id, toId: testRundown[1].id,
}); });
expect((newRundown[0] as OntimeEvent).id).toBe('1'); expect((newRundown[0] as OntimeEvent).id).toBe('1');
@@ -576,6 +576,7 @@ describe('calculateRuntimeDelays', () => {
colour: '', colour: '',
type: SupportedEvent.Event, type: SupportedEvent.Event,
revision: 0, revision: 0,
delay: 0,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
id: '659e1', id: '659e1',
@@ -603,6 +604,7 @@ describe('calculateRuntimeDelays', () => {
colour: '', colour: '',
type: SupportedEvent.Event, type: SupportedEvent.Event,
revision: 0, revision: 0,
delay: 0,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
id: '1c48f', id: '1c48f',
@@ -630,6 +632,7 @@ describe('calculateRuntimeDelays', () => {
colour: '', colour: '',
type: SupportedEvent.Event, type: SupportedEvent.Event,
revision: 0, revision: 0,
delay: 0,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
id: 'd48c2', id: 'd48c2',
@@ -657,6 +660,7 @@ describe('calculateRuntimeDelays', () => {
colour: '', colour: '',
type: SupportedEvent.Event, type: SupportedEvent.Event,
revision: 0, revision: 0,
delay: 0,
timeWarning: 120000, timeWarning: 120000,
timeDanger: 60000, timeDanger: 60000,
id: '2f185', id: '2f185',
@@ -935,7 +939,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
describe('custom fields', () => { describe('custom fields', () => {
describe('createCustomField()', () => { describe('createCustomField()', () => {
it('creates a field from given parameters', async () => { it('creates a field from given parameters', () => {
const expected = { const expected = {
Lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
@@ -944,14 +948,14 @@ describe('custom fields', () => {
}, },
}; };
const customField = await createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' }); const customField = createCustomField({ label: 'Lighting', type: 'string', colour: 'blue' });
expect(customField).toStrictEqual(expected); expect(customField).toStrictEqual(expected);
}); });
}); });
describe('editCustomField()', () => { describe('editCustomField()', () => {
it('edits a field with a given label', async () => { it('edits a field with a given label', () => {
await createCustomField({ label: 'Sound', type: 'string', colour: 'blue' }); createCustomField({ label: 'Sound', type: 'string', colour: 'blue' });
const expected = { const expected = {
Lighting: { Lighting: {
@@ -966,14 +970,14 @@ describe('custom fields', () => {
}, },
}; };
const customField = await editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' }); const customField = editCustomField('Sound', { label: 'Sound', type: 'string', colour: 'green' });
expect(customFieldChangelog).toStrictEqual(new Map()); expect(customFieldChangelog).toStrictEqual(new Map());
expect(customField).toStrictEqual(expected); expect(customField).toStrictEqual(expected);
}); });
it('renames a field to a new label', async () => { it('renames a field to a new label', () => {
const created = await createCustomField({ label: 'Video', type: 'string', colour: 'red' }); const created = createCustomField({ label: 'Video', type: 'string', colour: 'red' });
const expected = { const expected = {
Lighting: { Lighting: {
@@ -1015,10 +1019,10 @@ describe('custom fields', () => {
// We need to flush all scheduled tasks for the generate function to settle // We need to flush all scheduled tasks for the generate function to settle
vi.useFakeTimers(); vi.useFakeTimers();
const customField = await editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' }); const customField = editCustomField('Video', { label: 'AV', type: 'string', colour: 'red' });
expect(customField).toStrictEqual(expectedAfter); expect(customField).toStrictEqual(expectedAfter);
expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']])); expect(customFieldChangelog).toStrictEqual(new Map([['Video', 'AV']]));
await editCustomField('AV', { label: 'Video' }); editCustomField('AV', { label: 'Video' });
vi.runAllTimers(); vi.runAllTimers();
expect(customFieldChangelog).toStrictEqual(new Map()); expect(customFieldChangelog).toStrictEqual(new Map());
vi.useRealTimers(); vi.useRealTimers();
@@ -1026,7 +1030,7 @@ describe('custom fields', () => {
}); });
describe('removeCustomField()', () => { describe('removeCustomField()', () => {
it('deletes a field with a given label', async () => { it('deletes a field with a given label', () => {
const expected = { const expected = {
Lighting: { Lighting: {
label: 'Lighting', label: 'Lighting',
@@ -1040,7 +1044,7 @@ describe('custom fields', () => {
}, },
}; };
const customField = await removeCustomField('Sound'); const customField = removeCustomField('Sound');
expect(customField).toStrictEqual(expected); expect(customField).toStrictEqual(expected);
}); });
@@ -2,6 +2,7 @@ import {
CustomField, CustomField,
CustomFieldLabel, CustomFieldLabel,
CustomFields, CustomFields,
isOntimeBlock,
isOntimeDelay, isOntimeDelay,
isOntimeEvent, isOntimeEvent,
isPlayableEvent, isPlayableEvent,
@@ -31,14 +32,27 @@ type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
let persistedRundown: OntimeRundown = []; let persistedRundown: OntimeRundown = [];
let persistedCustomFields: CustomFields = {}; let persistedCustomFields: CustomFields = {};
/** Utility function gets to expose data */ /**
* Get the cached rundown without triggering regeneration
*/
export const getPersistedRundown = (): OntimeRundown => persistedRundown; export const getPersistedRundown = (): OntimeRundown => persistedRundown;
export const getCustomFields = (): CustomFields => persistedCustomFields; export const getCustomFields = (): CustomFields => persistedCustomFields;
let rundown: NormalisedRundown = {}; let normalisedRundown: NormalisedRundown = {};
let order: EventID[] = []; let order: EventID[] = [];
let revision = 0; let revision = 0;
/**
* all mutating functions will set this value if there is a need for re-generation
* but will only be cleared by the generate function
*/
let isStale = true; let isStale = true;
/** Allows safely setting the stale state without accidentally clearing it */
function setIsStale() {
isStale = true;
}
let totalDelay = 0; let totalDelay = 0;
let totalDuration = 0; let totalDuration = 0;
let firstStart: MaybeNumber = null; let firstStart: MaybeNumber = null;
@@ -72,18 +86,22 @@ export async function init(initialRundown: Readonly<OntimeRundown>, customFields
} }
/** /**
* Utility initialises cache * Utility generate cache
* @param rundown * @private should not be called outside of `rundownCache.ts`
*/ */
export function generate( export function generate(
initialRundown: OntimeRundown = persistedRundown, initialRundown: OntimeRundown = persistedRundown,
customFields: CustomFields = persistedCustomFields, customFields: CustomFields = persistedCustomFields,
) { ) {
function clearIsStale() {
isStale = false;
}
// we decided to re-write this dataset for every change // we decided to re-write this dataset for every change
// instead of maintaining logic to update it // instead of maintaining logic to update it
assignedCustomFields = {}; assignedCustomFields = {};
rundown = {}; normalisedRundown = {};
order = []; order = [];
links = {}; links = {};
firstStart = null; firstStart = null;
@@ -94,11 +112,12 @@ export function generate(
let lastEntry: PlayableEvent | null = null; let lastEntry: PlayableEvent | null = null;
for (let i = 0; i < initialRundown.length; i++) { for (let i = 0; i < initialRundown.length; i++) {
// TODO: filter properties that should not be persisted (eg: delay)
// we assign a reference to the current entry, this will be mutated in place // we assign a reference to the current entry, this will be mutated in place
const currentEntry = initialRundown[i]; const currentEntry = initialRundown[i];
if (isOntimeEvent(currentEntry)) { if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
// 1. handle links - mutates updatedEvent // 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links); handleLink(i, initialRundown, currentEntry, links);
@@ -145,24 +164,30 @@ export function generate(
lastEntry = currentEntry; lastEntry = currentEntry;
} }
} }
} } else if (isOntimeDelay(currentEntry)) {
// calculate delays
// calculate delays // !!! this must happen after handling the links
// !!! this must happen after handling the links
if (isOntimeDelay(currentEntry)) {
totalDelay += currentEntry.duration; totalDelay += currentEntry.duration;
} else if (isOntimeBlock(currentEntry)) {
// calculate block - nothing yet
} else {
// unknown - type skip it
// this is needed to get the type guard working when we assign the entry to the rundown
continue;
} }
// add id to order // add id to order
order.push(currentEntry.id); order.push(currentEntry.id);
// add entry to rundown // add entry to rundown
rundown[currentEntry.id] = currentEntry; normalisedRundown[currentEntry.id] = currentEntry;
} }
lastEnd = lastEntry?.timeEnd ?? null; lastEnd = lastEntry?.timeEnd ?? null;
isStale = false; clearIsStale();
customFieldChangelog.clear(); customFieldChangelog.clear();
return { rundown, order, links, totalDelay, totalDuration, assignedCustomFields };
//The return value is used for testing
return { rundown: normalisedRundown, order, links, totalDelay, totalDuration, assignedCustomFields };
} }
/** Returns an ID guaranteed to be unique */ /** Returns an ID guaranteed to be unique */
@@ -173,7 +198,7 @@ export function getUniqueId(): string {
let id = ''; let id = '';
do { do {
id = generateId(); id = generateId();
} while (Object.hasOwn(rundown, id)); } while (Object.hasOwn(normalisedRundown, id));
return id; return id;
} }
@@ -194,15 +219,15 @@ type RundownCache = {
}; };
/** /**
* Returns cached data * Returns the full rundown cache.
* @returns {RundownCache} * Will triggering regeneration if data is stale.
*/ */
export function get(): Readonly<RundownCache> { export function get(): Readonly<RundownCache> {
if (isStale) { if (isStale) {
generate(); generate();
} }
return { return {
rundown, rundown: normalisedRundown,
order, order,
revision, revision,
totalDelay, totalDelay,
@@ -212,6 +237,7 @@ export function get(): Readonly<RundownCache> {
/** /**
* Returns calculated metadata from rundown * Returns calculated metadata from rundown
* Will triggering regeneration if data is stale.
*/ */
export function getMetadata() { export function getMetadata() {
if (isStale) { if (isStale) {
@@ -227,7 +253,7 @@ export function getMetadata() {
}; };
} }
type CommonParams = { persistedRundown: OntimeRundown }; type CommonParams = { rundown: OntimeRundown };
type MutationParams<T> = T & CommonParams; type MutationParams<T> = T & CommonParams;
type MutatingReturn = { type MutatingReturn = {
newRundown: OntimeRundown; newRundown: OntimeRundown;
@@ -238,23 +264,14 @@ type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingRetur
/** /**
* Decorators injects data into mutation * Decorators injects data into mutation
* @param mutation * ensures order of operations when performing mutations
* @returns
*/ */
export function mutateCache<T extends object>(mutation: MutatingFn<T>) { export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
async function scopedMutation(params: T) { function scopedMutation(params: T) {
/** const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: persistedRundown });
* Marking the data set as stale
* doing it before calling the mutation, gives the function a chance
* to prevent recalculation by setting stale = false
*/
isStale = true;
const { newEvent, newRundown, didMutate } = mutation({ ...params, persistedRundown });
// early return without calling side effects // early return without calling side effects
if (!didMutate) { if (!didMutate) {
isStale = false;
return { newEvent, newRundown, didMutate }; return { newEvent, newRundown, didMutate };
} }
@@ -278,31 +295,34 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
} }
type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>; type AddArgs = MutationParams<{ atIndex: number; event: OntimeRundownEntry }>;
/**
export function add({ persistedRundown, atIndex, event }: AddArgs): Required<MutatingReturn> { * Add entry to rundown
*/
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeRundownEntry = { ...event }; const newEvent: OntimeRundownEntry = { ...event };
const newRundown = insertAtIndex(atIndex, newEvent, persistedRundown); const newRundown = insertAtIndex(atIndex, newEvent, rundown);
setIsStale();
return { newRundown, newEvent, didMutate: true }; return { newRundown, newEvent, didMutate: true };
} }
type RemoveArgs = MutationParams<{ eventIds: string[] }>; type RemoveArgs = MutationParams<{ eventIds: string[] }>;
/**
export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn { * Remove entry to rundown
const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id)); */
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
return { newRundown, didMutate: persistedRundown.length !== newRundown.length }; const newRundown = rundown.filter((event) => !eventIds.includes(event.id));
const didMutate = rundown.length !== newRundown.length;
if (didMutate) setIsStale();
return { newRundown, didMutate };
} }
export function removeAll(): MutatingReturn { export function removeAll(): MutatingReturn {
setIsStale();
return { newRundown: [], didMutate: true }; return { newRundown: [], didMutate: true };
} }
/** /**
* Utility function for patching events * Utility function for patching an existing event with new data
* @param eventFromRundown
* @param patch
* @returns
*/ */
function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry { function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRundownEntry>): OntimeRundownEntry {
if (isOntimeEvent(eventFromRundown)) { if (isOntimeEvent(eventFromRundown)) {
@@ -315,120 +335,133 @@ function makeEvent(eventFromRundown: OntimeRundownEntry, patch: Partial<OntimeRu
} }
type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>; type EditArgs = MutationParams<{ eventId: string; patch: Partial<OntimeRundownEntry> }>;
/**
export function edit({ persistedRundown, eventId, patch }: EditArgs): Required<MutatingReturn> { * Apply patch to an entry with given id
const indexAt = persistedRundown.findIndex((event) => event.id === eventId); */
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
const indexAt = rundown.findIndex((event) => event.id === eventId);
if (indexAt < 0) { if (indexAt < 0) {
throw new Error('Event not found'); throw new Error('Event not found');
} }
if (patch?.type && persistedRundown[indexAt].type !== patch.type) { if (patch?.type && rundown[indexAt].type !== patch.type) {
throw new Error('Invalid event type'); throw new Error('Invalid event type');
} }
const eventInMemory = persistedRundown[indexAt]; const eventInMemory = rundown[indexAt];
if (!hasChanges(eventInMemory, patch)) { if (!hasChanges(eventInMemory, patch)) {
isStale = false; return { newRundown: rundown, newEvent: eventInMemory, didMutate: false };
return { newRundown: persistedRundown, newEvent: eventInMemory, didMutate: false };
} }
const newEvent = makeEvent(eventInMemory, patch); const newEvent = makeEvent(eventInMemory, patch);
const newRundown = [...persistedRundown]; const newRundown = [...rundown];
newRundown[indexAt] = newEvent; newRundown[indexAt] = newEvent;
// check whether the data warrants recalculation of cache // check whether the data warrants recalculation of cache
const makeStale = isDataStale(patch); const makeStale = isDataStale(patch);
if (!makeStale) { if (makeStale) {
rundown[newEvent.id] = newEvent; setIsStale();
} else {
normalisedRundown[newEvent.id] = newEvent;
} }
isStale = makeStale;
return { newRundown, newEvent, didMutate: true }; return { newRundown, newEvent, didMutate: true };
} }
type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>; type BatchEditArgs = MutationParams<{ eventIds: string[]; patch: Partial<OntimeRundownEntry> }>;
/**
export function batchEdit({ persistedRundown, eventIds, patch }: BatchEditArgs): MutatingReturn { * Apply patch to multiple entries
*/
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
const ids = new Set(eventIds); const ids = new Set(eventIds);
const newRundown = []; const newRundown = [];
for (let i = 0; i < persistedRundown.length; i++) { for (let i = 0; i < rundown.length; i++) {
if (ids.has(persistedRundown[i].id)) { if (ids.has(rundown[i].id)) {
if (patch?.type && persistedRundown[i].type !== patch.type) { if (patch?.type && rundown[i].type !== patch.type) {
continue; continue;
} }
const newEvent = makeEvent(persistedRundown[i], patch); const newEvent = makeEvent(rundown[i], patch);
newRundown.push(newEvent); newRundown.push(newEvent);
} else { } else {
newRundown.push(persistedRundown[i]); newRundown.push(rundown[i]);
} }
} }
setIsStale();
return { newRundown, didMutate: true }; return { newRundown, didMutate: true };
} }
type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>; type ReorderArgs = MutationParams<{ eventId: string; from: number; to: number }>;
/**
export function reorder({ persistedRundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> { * Redorder two entries
const event = persistedRundown[from]; */
export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const event = rundown[from];
if (!event || eventId !== event.id) { if (!event || eventId !== event.id) {
throw new Error('Event not found'); throw new Error('Event not found');
} }
const newRundown = reorderArray(persistedRundown, from, to); const newRundown = reorderArray(rundown, from, to);
for (let i = from; i <= to; i++) { for (let i = from; i <= to; i++) {
const event = newRundown.at(i); const event = newRundown.at(i);
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
event.revision += 1; event.revision += 1;
} }
} }
setIsStale();
return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true }; return { newRundown, newEvent: newRundown.at(from) as OntimeRundownEntry, didMutate: true };
} }
type ApplyDelayArgs = MutationParams<{ eventId: string }>; type ApplyDelayArgs = MutationParams<{ eventId: string }>;
/**
export function applyDelay({ persistedRundown, eventId }: ApplyDelayArgs): MutatingReturn { * Apply a delay
const newRundown = apply(eventId, persistedRundown); */
export function applyDelay({ rundown, eventId }: ApplyDelayArgs): MutatingReturn {
const newRundown = apply(eventId, rundown);
setIsStale();
return { newRundown, didMutate: true }; return { newRundown, didMutate: true };
} }
type SwapArgs = MutationParams<{ fromId: string; toId: string }>; type SwapArgs = MutationParams<{ fromId: string; toId: string }>;
/**
* Swap two entries
*/
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
const indexA = rundown.findIndex((event) => event.id === fromId);
const eventA = rundown.at(indexA);
export function swap({ persistedRundown, fromId, toId }: SwapArgs): MutatingReturn { const indexB = rundown.findIndex((event) => event.id === toId);
const indexA = persistedRundown.findIndex((event) => event.id === fromId); const eventB = rundown.at(indexB);
const eventA = persistedRundown.at(indexA);
const indexB = persistedRundown.findIndex((event) => event.id === toId);
const eventB = persistedRundown.at(indexB);
if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) { if (!isOntimeEvent(eventA) || !isOntimeEvent(eventB)) {
throw new Error('Swap only available for OntimeEvents'); throw new Error('Swap only available for OntimeEvents');
} }
const { newA, newB } = swapEventData(eventA, eventB); const { newA, newB } = swapEventData(eventA, eventB);
const newRundown = [...persistedRundown]; const newRundown = [...rundown];
newRundown[indexA] = newA; newRundown[indexA] = newA;
(newRundown[indexA] as OntimeEvent).revision += 1; (newRundown[indexA] as OntimeEvent).revision += 1;
newRundown[indexB] = newB; newRundown[indexB] = newB;
(newRundown[indexB] as OntimeEvent).revision += 1; (newRundown[indexB] as OntimeEvent).revision += 1;
setIsStale();
return { newRundown, didMutate: true }; return { newRundown, didMutate: true };
} }
/** /**
* Invalidates service cache if a custom field is used * Utility for invalidating service cache if a custom field is used
* @param label
*/ */
function invalidateIfUsed(label: CustomFieldLabel) { function invalidateIfUsed(label: CustomFieldLabel) {
if (label in assignedCustomFields) { if (label in assignedCustomFields) {
isStale = true; setIsStale();
} }
// if the field was in use, we mark the cache as stale // if the field was in use, we mark the cache as stale
if (label in assignedCustomFields) { if (label in assignedCustomFields) {
isStale = true; setIsStale();
} }
// ... and schedule a cache update // ... and schedule a cache update
// schedule a non priority cache update // schedule a non priority cache update
@@ -439,10 +472,9 @@ function invalidateIfUsed(label: CustomFieldLabel) {
} }
/** /**
* Schedules a non priority custom field persist * Utility for scheduling a non priority custom field persist
* @param persistedCustomFields
*/ */
function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) { function scheduleCustomFieldPersist() {
setImmediate(async () => { setImmediate(async () => {
await getDataProvider().setCustomFields(persistedCustomFields); await getDataProvider().setCustomFields(persistedCustomFields);
}); });
@@ -450,12 +482,15 @@ function scheduleCustomFieldPersist(persistedCustomFields: CustomFields) {
/** /**
* Sanitises and creates a custom field in the database * Sanitises and creates a custom field in the database
* @param field
* @returns
*/ */
export const createCustomField = async (field: CustomField) => { export function createCustomField(field: CustomField): CustomFields {
const { label, type, colour } = field; const { label, type, colour } = field;
const key = customFieldLabelToKey(label); const key = customFieldLabelToKey(label);
if (key === null) {
throw new Error('Unable to convert label to a valid key');
}
// check if label already exists // check if label already exists
const alreadyExists = Object.hasOwn(persistedCustomFields, key); const alreadyExists = Object.hasOwn(persistedCustomFields, key);
@@ -466,18 +501,15 @@ export const createCustomField = async (field: CustomField) => {
// update object and persist // update object and persist
persistedCustomFields[key] = { label, type, colour }; persistedCustomFields[key] = { label, type, colour };
scheduleCustomFieldPersist(persistedCustomFields); scheduleCustomFieldPersist();
return persistedCustomFields; return persistedCustomFields;
}; }
/** /**
* Edits an existing custom field in the database * Edits an existing custom field in the database
* @param key
* @param newField
* @returns
*/ */
export const editCustomField = async (key: string, newField: Partial<CustomField>) => { export function editCustomField(key: string, newField: Partial<CustomField>): CustomFields {
if (!(key in persistedCustomFields)) { if (!(key in persistedCustomFields)) {
throw new Error('Could not find label'); throw new Error('Could not find label');
} }
@@ -487,7 +519,14 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
throw new Error('Change of field type is not allowed'); throw new Error('Change of field type is not allowed');
} }
if (newField.label === undefined) {
throw new Error('Missing label');
}
const newKey = customFieldLabelToKey(newField.label); const newKey = customFieldLabelToKey(newField.label);
if (newKey === null) {
throw new Error('Unable to convert label to a valid key');
}
persistedCustomFields[newKey] = { ...existingField, ...newField }; persistedCustomFields[newKey] = { ...existingField, ...newField };
if (key !== newKey) { if (key !== newKey) {
@@ -495,23 +534,22 @@ export const editCustomField = async (key: string, newField: Partial<CustomField
customFieldChangelog.set(key, newKey); customFieldChangelog.set(key, newKey);
} }
scheduleCustomFieldPersist(persistedCustomFields); scheduleCustomFieldPersist();
invalidateIfUsed(key); invalidateIfUsed(key);
return persistedCustomFields; return persistedCustomFields;
}; }
/** /**
* Deletes a custom field from the database * Deletes a custom field from the database
* @param label
*/ */
export const removeCustomField = async (label: string) => { export function removeCustomField(label: string): CustomFields {
if (label in persistedCustomFields) { if (label in persistedCustomFields) {
delete persistedCustomFields[label]; delete persistedCustomFields[label];
} }
scheduleCustomFieldPersist(persistedCustomFields); scheduleCustomFieldPersist();
invalidateIfUsed(label); invalidateIfUsed(label);
return persistedCustomFields; return persistedCustomFields;
}; }
@@ -86,7 +86,7 @@ export function handleCustomField(
// rename the property if it is in the changelog // rename the property if it is in the changelog
if (customFieldChangelog.has(field)) { if (customFieldChangelog.has(field)) {
const oldData = mutableEvent.custom[field]; const oldData = mutableEvent.custom[field];
const newLabel = customFieldChangelog.get(field); const newLabel = customFieldChangelog.get(field) as string; // it os OK to cast to string here since we already checked that it existed
mutableEvent.custom[newLabel] = oldData; mutableEvent.custom[newLabel] = oldData;
delete mutableEvent.custom[field]; delete mutableEvent.custom[field];
@@ -143,6 +143,6 @@ export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
*/ */
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean { export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
return Object.keys(newEvent).some( return Object.keys(newEvent).some(
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key] !== newEvent[key], (key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
); );
} }
@@ -36,6 +36,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: false, isPublic: false,
skip: false, skip: false,
colour: 'red', colour: 'red',
delay: 0,
revision: 0, revision: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
@@ -82,6 +83,7 @@ describe('cellRequestFromEvent()', () => {
skip: false, skip: false,
colour: 'red', colour: 'red',
revision: 0, revision: 0,
delay: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
timeDanger: 0, timeDanger: 0,
@@ -129,6 +131,7 @@ describe('cellRequestFromEvent()', () => {
skip: false, skip: false,
colour: 'red', colour: 'red',
revision: 0, revision: 0,
delay: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
timeDanger: 0, timeDanger: 0,
@@ -174,6 +177,7 @@ describe('cellRequestFromEvent()', () => {
isPublic: true, isPublic: true,
skip: false, skip: false,
colour: 'red', colour: 'red',
delay: 0,
revision: 0, revision: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
@@ -207,6 +211,7 @@ describe('cellRequestFromEvent()', () => {
skip: false, skip: false,
colour: 'red', colour: 'red',
revision: 0, revision: 0,
delay: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
timeDanger: 0, timeDanger: 0,
@@ -240,6 +245,7 @@ describe('cellRequestFromEvent()', () => {
skip: false, skip: false,
colour: 'red', colour: 'red',
revision: 0, revision: 0,
delay: 0,
id: '1358', id: '1358',
timeWarning: 0, timeWarning: 0,
timeDanger: 0, timeDanger: 0,
+1
View File
@@ -384,6 +384,7 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip, skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note), note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour), colour: makeString(patchEvent.colour, originalEvent.colour),
delay: 0, //delay is always regenerated by the cache
// short circuit empty string // short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue), cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
revision: originalEvent.revision, revision: originalEvent.revision,
@@ -38,7 +38,7 @@ export type OntimeEvent = OntimeBaseEvent & {
skip: boolean; skip: boolean;
colour: string; colour: string;
revision: number; revision: number;
delay?: number; // calculated at runtime delay: number; // calculated at runtime
timeWarning: number; timeWarning: number;
timeDanger: number; timeDanger: number;
custom: EventCustomFields; custom: EventCustomFields;
@@ -2,7 +2,6 @@ import { isAlphanumericWithSpace } from '../regex-utils/isAlphanumeric.js';
/** /**
* @description Transforms a Custom field label into a valid key or returns null if not possible * @description Transforms a Custom field label into a valid key or returns null if not possible
* @returns {string | null}
*/ */
export const customFieldLabelToKey = (label: string): string | null => { export const customFieldLabelToKey = (label: string): string | null => {
if (isAlphanumericWithSpace(label)) { if (isAlphanumericWithSpace(label)) {