refactor: cuesheet design review

fix: parsing of custom fields for blocks
refactor: extract cuesheet settings
refactor: cuesheet actions
refactor: improve cuesheet performance on resizing
This commit is contained in:
Carlos Valente
2025-06-29 08:51:38 +02:00
committed by Carlos Valente
parent 0726c04f20
commit 8ad260d28a
61 changed files with 1421 additions and 734 deletions
@@ -12,6 +12,7 @@ interface LegacyData extends Partial<DatabaseModel> {
}
export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings {
// TODO(v4): move to migration script
/**
* Leaving a path for migrating users to the new automations
* This should be removed after a few releases
@@ -213,6 +213,64 @@ describe('parseRundown()', () => {
expect((parsedRundown.entries['2'] as OntimeEvent).custom).toStrictEqual({ sound: 'loud' });
});
it('removes empty custom fields', () => {
const rundown = {
id: 'test',
title: '',
order: ['1', '2'],
flatOrder: ['1', '2', '21'],
entries: {
'1': makeOntimeEvent({ id: '1', custom: { lighting: 'yes' } }),
'2': makeOntimeBlock({ id: '2', entries: ['21'], custom: { lighting: '' } }),
'21': makeOntimeEvent({ id: '21', custom: { lighting: '' } }),
},
revision: 1,
} as Rundown;
const customFields: CustomFields = {
lighting: {
type: 'string',
colour: 'red',
label: 'lighting',
},
};
const parsedRundown = parseRundown(rundown, customFields);
expect((parsedRundown.entries['1'] as OntimeEvent).custom).toStrictEqual({ lighting: 'yes' });
expect((parsedRundown.entries['2'] as OntimeBlock).custom).not.toHaveProperty('lighting');
expect((parsedRundown.entries['21'] as OntimeEvent).custom).not.toHaveProperty('lighting');
});
it('parses data in blocks', () => {
const rundown = {
id: 'test',
title: '',
order: ['block'],
flatOrder: ['block'],
isNextDay: false,
entries: {
block: makeOntimeBlock({
id: 'block',
title: 'block-title',
note: 'block-note',
colour: 'red',
entries: ['1', '2'],
}),
'1': makeOntimeEvent({ id: '1' }),
},
revision: 1,
} as Rundown;
const parsedRundown = parseRundown(rundown, {});
expect(parsedRundown.order.length).toEqual(1);
expect(parsedRundown.entries.block).toMatchObject({
title: 'block-title',
note: 'block-note',
colour: 'red',
entries: ['1'],
});
});
it('parses events nested in blocks', () => {
const rundown = {
id: 'test',
@@ -19,10 +19,10 @@ import {
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
import { defaultRundown } from '../../models/dataModel.js';
import { delay as delayDef, block as blockDef } from '../../models/eventsDefinition.js';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, createEvent } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -104,14 +104,7 @@ export function parseRundown(
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newEvent.custom[field];
}
}
cleanupCustomFields(newEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
@@ -128,14 +121,7 @@ export function parseRundown(
continue;
}
// for every field in custom, check that a key exists in customfields
for (const field in newNestedEvent.custom) {
if (!Object.hasOwn(parsedCustomFields, field)) {
emitError?.(`Custom field ${field} not found`);
delete newNestedEvent.custom[field];
}
}
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
if (newNestedEvent) {
@@ -145,16 +131,13 @@ export function parseRundown(
}
}
newEvent = {
...blockDef,
title: event.title,
note: event.note,
entries: event.entries?.filter((eventId) => Object.hasOwn(rundown.entries, eventId)) ?? [],
isNextDay: event.isNextDay,
colour: event.colour,
custom: { ...event.custom },
id,
};
newEvent = createBlock({ ...structuredClone(event), id });
// ensure entries exist
if (event.entries?.length > 0) {
newEvent.entries = event.entries.filter((eventId) => Object.hasOwn(rundown.entries, eventId));
}
// ensure custom fields are valid
cleanupCustomFields(newEvent.custom, parsedCustomFields);
} else {
emitError?.('Unknown event type, skipping');
continue;
@@ -1,4 +1,6 @@
import {
CustomFields,
EntryCustomFields,
EntryId,
isOntimeBlock,
isOntimeDelay,
@@ -60,7 +62,7 @@ export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDel
throw new Error('Invalid event type');
}
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
export function createEventPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
if (Object.keys(patchEvent).length === 0) {
return originalEvent;
}
@@ -101,18 +103,52 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
};
}
export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial<OntimeBlock>): OntimeBlock {
if (Object.keys(patchBlock).length === 0) {
return originalBlock;
}
const maybeTargetDuration = () => {
if (typeof patchBlock.targetDuration === 'number') {
return patchBlock.targetDuration;
}
if (patchBlock.targetDuration === null || patchBlock.targetDuration === '') {
return null;
}
return originalBlock.targetDuration;
};
return {
id: originalBlock.id,
type: SupportedEntry.Block,
title: makeString(patchBlock.title, originalBlock.title),
note: makeString(patchBlock.note, originalBlock.note),
entries: patchBlock.entries ?? originalBlock.entries,
isNextDay: typeof patchBlock.isNextDay === 'boolean' ? patchBlock.isNextDay : originalBlock.isNextDay,
targetDuration: maybeTargetDuration(),
colour: makeString(patchBlock.colour, originalBlock.colour),
revision: originalBlock.revision,
timeStart: originalBlock.timeStart,
timeEnd: originalBlock.timeEnd,
duration: originalBlock.duration,
isFirstLinked: originalBlock.isFirstLinked,
custom: { ...originalBlock.custom, ...patchBlock.custom },
};
}
/**
* Utility function for patching an existing event with new data
* Increments the revision of the event when applying the patch
*/
export function applyPatchToEntry<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
const newEvent = createEventPatch(eventFromRundown, patch as Partial<OntimeEvent>);
newEvent.revision++;
return newEvent as T;
}
if (isOntimeBlock(eventFromRundown)) {
const newBlock: OntimeBlock = { ...eventFromRundown, ...patch };
const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial<OntimeBlock>);
newBlock.revision++;
return newBlock as T;
}
@@ -139,7 +175,7 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number
cue,
...eventDef,
};
const event = createPatch(baseEvent, eventArgs);
const event = createEventPatch(baseEvent, eventArgs);
return event;
};
@@ -359,6 +395,22 @@ export function getInsertAfterId(rundown: Rundown, afterId?: EntryId, beforeId?:
return null;
}
/**
* Sanitises custom fields in an entry by removing fields
* - if it does not exist in the project
* - if the value is empty string
* Mutates the entryCustomFields object
*/
export function cleanupCustomFields(entryCustomFields: EntryCustomFields, projectCustomFields: CustomFields) {
for (const field in entryCustomFields) {
if (!Object.hasOwn(projectCustomFields, field)) {
delete entryCustomFields[field];
} else if (entryCustomFields[field] === '') {
delete entryCustomFields[field];
}
}
}
/**
* converts an index from the timedEventOrder to an index in the playableEventOrder
* or returns null if it can not be found