mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 09:59:08 +00:00
refactor: create transaction system and apply to adding entry (#1620)
* refactor: create transaction system and apply to adding entry * refactor: migrate edit mutations to transaction * refactor: migrate delete mutation to transaction * refactor: migrate reorder mutation to transaction * refactor: migrate apply delay to transaction * refactor: migrate swapEvents to transaction * refactor: migrate clone to transaction * refactor: migrate group/ungroup transactions * refactor: simplify mutations * refactor: migrate getters * chore: add tests to processRundown()
This commit is contained in:
@@ -1,295 +1,10 @@
|
||||
import {
|
||||
CustomFields,
|
||||
OntimeBlock,
|
||||
OntimeDelay,
|
||||
OntimeEvent,
|
||||
OntimeEntry,
|
||||
isOntimeBlock,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
PatchWithId,
|
||||
EventPostPayload,
|
||||
Rundown,
|
||||
EntryId,
|
||||
} from 'ontime-types';
|
||||
import { getCueCandidate } from 'ontime-utils';
|
||||
import { CustomFields, Rundown } from 'ontime-types';
|
||||
|
||||
import { delay as delayDef } from '../../models/eventsDefinition.js';
|
||||
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
|
||||
import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { runtimeService } from '../runtime-service/RuntimeService.js';
|
||||
|
||||
import * as cache from './rundownCache.js';
|
||||
import { getPreviousId } from './rundownUtils.js';
|
||||
|
||||
type CompleteEntry<T> =
|
||||
T extends Partial<OntimeEvent>
|
||||
? OntimeEvent
|
||||
: T extends Partial<OntimeDelay>
|
||||
? OntimeDelay
|
||||
: T extends Partial<OntimeBlock>
|
||||
? OntimeBlock
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Generates a fully formed RundownEntry of the patch type
|
||||
*/
|
||||
function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
|
||||
eventData: T,
|
||||
afterId?: string,
|
||||
): CompleteEntry<T> {
|
||||
if (isOntimeEvent(eventData)) {
|
||||
const currentRundown = cache.getCurrentRundown();
|
||||
return createEvent(
|
||||
eventData,
|
||||
getCueCandidate(currentRundown.entries, currentRundown.order, afterId),
|
||||
) as CompleteEntry<T>;
|
||||
}
|
||||
|
||||
const id = eventData.id || cache.getUniqueId();
|
||||
|
||||
if (isOntimeDelay(eventData)) {
|
||||
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>;
|
||||
}
|
||||
|
||||
// TODO(v4): allow user to provide a larger patch of the block entry
|
||||
if (isOntimeBlock(eventData)) {
|
||||
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
|
||||
}
|
||||
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a new event with given data
|
||||
*/
|
||||
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
|
||||
// 1. we allow the user to provide an ID, but make sure it is unique
|
||||
if (eventData?.id && cache.hasId(eventData.id)) {
|
||||
throw new Error(`Event with ID ${eventData.id} already exists`);
|
||||
}
|
||||
|
||||
// 2. if the user provides a parent (inside a group), we make sure it exists and it is a group
|
||||
let parent: EntryId | null = null;
|
||||
if ('parent' in eventData && eventData.parent != null) {
|
||||
const maybeParent = cache.getCurrentRundown().entries[eventData.parent];
|
||||
if (!maybeParent || !isOntimeBlock(maybeParent)) {
|
||||
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
|
||||
}
|
||||
parent = eventData.parent;
|
||||
}
|
||||
|
||||
// 3. if the user provides an after or before ID, we make sure it exists
|
||||
if (eventData?.after !== undefined) {
|
||||
if (!cache.hasId(eventData.after)) {
|
||||
throw new Error(`Event with ID ${eventData.after} not found`);
|
||||
}
|
||||
}
|
||||
if (eventData?.before !== undefined) {
|
||||
if (!cache.hasId(eventData.before)) {
|
||||
throw new Error(`Event with ID ${eventData.before} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const afterId = getPreviousId(eventData?.after, eventData?.before);
|
||||
|
||||
// generate a fully formed entry from the patch
|
||||
const sanitisedEntry = generateEvent(eventData, afterId);
|
||||
|
||||
// modify rundown
|
||||
const scopedMutation = cache.mutateCache(cache.add);
|
||||
const { newEvent } = await scopedMutation({ afterId, parent, entry: sanitisedEntry });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: [sanitisedEntry.id], external: true });
|
||||
|
||||
// we know this mutation returns an OntimeEntry
|
||||
return newEvent as OntimeEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes event by its ID
|
||||
*/
|
||||
export async function deleteEvent(eventIds: EntryId[]) {
|
||||
const scopedMutation = cache.mutateCache(cache.remove);
|
||||
const { didMutate, changeList } = await scopedMutation({ eventIds });
|
||||
|
||||
if (!didMutate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: changeList, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* deletes all entries in database
|
||||
*/
|
||||
export async function deleteAllEntries() {
|
||||
const scopedMutation = cache.mutateCache(cache.removeAll);
|
||||
await scopedMutation({});
|
||||
|
||||
// notify event loader that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply patch to an element in rundown
|
||||
* @param patch
|
||||
*/
|
||||
export async function editEvent(patch: PatchWithId) {
|
||||
if (isOntimeEvent(patch) && patch?.cue === '') {
|
||||
throw new Error('Cue value invalid');
|
||||
}
|
||||
|
||||
const scopedMutation = cache.mutateCache(cache.edit);
|
||||
const { newEvent, didMutate } = await scopedMutation({ patch, eventId: patch.id });
|
||||
|
||||
// short circuit if nothing changed
|
||||
if (!didMutate) {
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: [patch.id], external: true });
|
||||
|
||||
return newEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a patch to several elements in a rundown
|
||||
* @param ids
|
||||
* @param data
|
||||
*/
|
||||
export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>) {
|
||||
const scopedMutation = cache.mutateCache(cache.batchEdit);
|
||||
await scopedMutation({ patch: data, eventIds: ids });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: ids, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* reorders a given entry
|
||||
*/
|
||||
export async function reorderEntry(
|
||||
entryId: EntryId,
|
||||
destinationId: EntryId,
|
||||
order: 'before' | 'after' | 'insert',
|
||||
): Promise<Rundown> {
|
||||
const scopedMutation = cache.mutateCache(cache.reorder);
|
||||
const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: changeList, external: true });
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a delay into the rundown effectively changing the schedule
|
||||
* The applied delay is deleted
|
||||
* @param delayId
|
||||
*/
|
||||
export async function applyDelay(delayId: EntryId) {
|
||||
const scopedMutation = cache.mutateCache(cache.applyDelay);
|
||||
await scopedMutation({ delayId });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an entry, ensuring that all dependencies are preserved
|
||||
*/
|
||||
export async function cloneEntry(entryId: EntryId) {
|
||||
const scopedMutation = cache.mutateCache(cache.clone);
|
||||
const { newRundown, newEvent } = await scopedMutation({ entryId });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
if (isOntimeBlock(newEvent)) {
|
||||
notifyChanges({ timer: newEvent.events, external: true });
|
||||
} else if (isOntimeEvent(newEvent)) {
|
||||
notifyChanges({ timer: [newEvent.id], external: true });
|
||||
} else if (isOntimeDelay(newEvent)) {
|
||||
notifyChanges({ external: true });
|
||||
}
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a block from the rundown and moves all its children to the top level
|
||||
*/
|
||||
export async function ungroupEntries(blockId: EntryId) {
|
||||
const scopedMutation = cache.mutateCache(cache.ungroup);
|
||||
const { newRundown } = await scopedMutation({ blockId });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// we dont need to modify the timer since the grouping does not affect the runtime
|
||||
notifyChanges({ external: true });
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a list of entries into a block
|
||||
*/
|
||||
export async function groupEntries(entryIds: EntryId[]) {
|
||||
const scopedMutation = cache.mutateCache(cache.groupEntries);
|
||||
const { newRundown } = await scopedMutation({ entryIds });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// we dont need to modify the timer since the grouping does not affect the runtime
|
||||
notifyChanges({ external: true });
|
||||
|
||||
return newRundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* swaps two events
|
||||
* @param {string} from - id of event from
|
||||
* @param {string} to - id of event to
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function swapEvents(from: string, to: string) {
|
||||
const scopedMutation = cache.mutateCache(cache.swap);
|
||||
await scopedMutation({ fromId: from, toId: to });
|
||||
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange();
|
||||
|
||||
// notify timer and external services of change
|
||||
notifyChanges({ timer: true, external: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces update in the store
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { OntimeEvent, SupportedEntry } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { apply } from '../delayUtils.js';
|
||||
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
describe('apply()', () => {
|
||||
it('applies a positive delay to the rundown', () => {
|
||||
const testRundown = makeRundown({
|
||||
revision: 0,
|
||||
order: ['delay', '1', '2', '3', '4', '5'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 10 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
|
||||
'3': makeOntimeBlock({ id: '3' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.revision).toBe(1);
|
||||
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 10, timeEnd: 20, duration: 10, revision: 2 },
|
||||
'2': { id: '2', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
|
||||
'3': { id: '3' },
|
||||
'4': { id: '4', timeStart: 30, timeEnd: 40, duration: 10, revision: 2, linkStart: false },
|
||||
'5': { id: '5', timeStart: 40, timeEnd: 50, duration: 10, revision: 2, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('applies negative delays', () => {
|
||||
const testRundown = makeRundown({
|
||||
revision: 0,
|
||||
order: ['delay', '1', '2', '3', '4', '5'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -10 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 10, duration: 10 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 10, timeEnd: 20, duration: 10, linkStart: true }),
|
||||
'3': makeOntimeBlock({ id: '3' }),
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 20, timeEnd: 30, duration: 10, linkStart: false }),
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 30, timeEnd: 40, duration: 10, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.revision).toBe(1);
|
||||
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 0, timeEnd: 10, duration: 10, revision: 2 },
|
||||
'2': { id: '2', timeStart: 0, timeEnd: 10, duration: 10, revision: 2, linkStart: false },
|
||||
'3': { id: '3' },
|
||||
'4': { id: '4', timeStart: 10, timeEnd: 20, duration: 10, revision: 2, linkStart: false },
|
||||
'5': { id: '5', timeStart: 20, timeEnd: 30, duration: 10, revision: 2, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should account for minimum duration and start when applying negative delays', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['delay', '1', '2'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': {
|
||||
id: '1',
|
||||
type: SupportedEntry.Event,
|
||||
timeStart: 0,
|
||||
timeEnd: 100,
|
||||
duration: 100,
|
||||
revision: 2,
|
||||
} as OntimeEvent,
|
||||
'2': {
|
||||
id: '2',
|
||||
type: SupportedEntry.Event,
|
||||
timeStart: 50,
|
||||
timeEnd: 100,
|
||||
duration: 50,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('unlinks events to maintain gaps when applying positive delays', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['1', 'delay', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': {
|
||||
id: '1',
|
||||
timeStart: 0,
|
||||
timeEnd: 100,
|
||||
duration: 100,
|
||||
revision: 1,
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains links if there is no gap', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['delay', '1', '2'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': {
|
||||
id: '1',
|
||||
timeStart: 50,
|
||||
timeEnd: 150,
|
||||
duration: 100,
|
||||
revision: 2,
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: true,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('unlinks events to maintain gaps when applying negative delays', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['1', 'delay', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: -50 }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 },
|
||||
'2': {
|
||||
id: '2',
|
||||
timeStart: 50,
|
||||
timeEnd: 100,
|
||||
duration: 50,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gaps reduce positive delay', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['delay', '1', '2', '3', '4', '5'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
// gap 50
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
|
||||
// gap 0
|
||||
'3': makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
|
||||
// gap 50
|
||||
'4': makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
|
||||
// linked
|
||||
'5': makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2', '3', '4', '5']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 0 + 100, timeEnd: 100 + 100, duration: 100, revision: 2 },
|
||||
// gap 50 (100 - 50)
|
||||
'2': { id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
|
||||
// gap 50 (50 - 50)
|
||||
'3': { id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
|
||||
// gap (delay is 0)
|
||||
'4': { id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
|
||||
// linked
|
||||
'5': { id: '5', timeStart: 350, timeEnd: 400, duration: 50, revision: 1, linkStart: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('gaps reduce positive delay (2)', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['delay', '1', '2'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 2 * MILLIS_PER_HOUR }),
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
gap: 0,
|
||||
dayOffset: 0,
|
||||
timeStart: 46800000, // 13:00:00
|
||||
timeEnd: 50400000, // 14:00:00
|
||||
duration: MILLIS_PER_HOUR,
|
||||
}),
|
||||
// gap 1h
|
||||
'2': makeOntimeEvent({
|
||||
id: '2',
|
||||
gap: 1 * MILLIS_PER_HOUR,
|
||||
dayOffset: 0,
|
||||
timeStart: 54000000, // 15:00:00
|
||||
timeEnd: 57600000, // 16:00:00
|
||||
duration: MILLIS_PER_HOUR,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', '2']);
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': { id: '1', timeStart: 54000000 /* 16 */, revision: 2 },
|
||||
// gap 1h (2h - 1h)
|
||||
'2': { id: '2', timeStart: 57600000 /* 16 */, revision: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('removes empty delays without applying changes', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['delay', '1'],
|
||||
entries: {
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 0 }),
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1']);
|
||||
expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } });
|
||||
});
|
||||
|
||||
it('removes delays in last position without applying changes', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['1', 'delay'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 100 }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1']);
|
||||
expect(testRundown.entries).toMatchObject({ '1': { id: '1', timeStart: 0, timeEnd: 100, duration: 100 } });
|
||||
});
|
||||
|
||||
it('unlinks events to across blocks is it is the first event after the delay', () => {
|
||||
const testRundown = makeRundown({
|
||||
order: ['1', 'delay', 'block', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100, revision: 1 }),
|
||||
delay: makeOntimeDelay({ id: 'delay', duration: 50 }),
|
||||
block: makeOntimeBlock({ id: 'block' }),
|
||||
'2': makeOntimeEvent({ id: '2', timeStart: 100, timeEnd: 150, duration: 50, revision: 1, linkStart: true }),
|
||||
},
|
||||
});
|
||||
|
||||
apply('delay', testRundown);
|
||||
expect(testRundown.order).toMatchObject(['1', 'block', '2']);
|
||||
|
||||
expect(testRundown.entries).toMatchObject({
|
||||
'1': {
|
||||
id: '1',
|
||||
timeStart: 0,
|
||||
timeEnd: 100,
|
||||
duration: 100,
|
||||
revision: 1,
|
||||
},
|
||||
block: { id: 'block' },
|
||||
'2': {
|
||||
id: '2',
|
||||
timeStart: 150,
|
||||
timeEnd: 200,
|
||||
duration: 50,
|
||||
linkStart: false,
|
||||
revision: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,5 @@
|
||||
import { CustomFields, EndAction, OntimeEvent, SupportedEntry, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import {
|
||||
addToCustomAssignment,
|
||||
calculateDayOffset,
|
||||
handleCustomField,
|
||||
hasChanges,
|
||||
isDataStale,
|
||||
} from '../rundownCache.utils.js';
|
||||
import { CustomFields, SupportedEntry } from 'ontime-types';
|
||||
import { addToCustomAssignment, calculateDayOffset, handleCustomField } from '../rundownCache.utils.js';
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
import { makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
@@ -135,55 +129,6 @@ describe('handleCustomField()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDataStale()', () => {
|
||||
it('is stale if data contains timers', () => {
|
||||
const needsRecompute = [
|
||||
{ timeStart: 10 },
|
||||
{ timeEnd: 10 },
|
||||
{ duration: 10 },
|
||||
{ linkStart: true },
|
||||
{ timerStrategy: TimeStrategy.LockDuration },
|
||||
];
|
||||
|
||||
for (const testCase of needsRecompute) {
|
||||
expect(isDataStale(testCase)).toBe(true);
|
||||
}
|
||||
expect.assertions(needsRecompute.length);
|
||||
});
|
||||
|
||||
it('is not stale if data contains auxiliary dataset', () => {
|
||||
expect(
|
||||
isDataStale({
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
note: 'note',
|
||||
endAction: EndAction.LoadNext,
|
||||
timerType: TimerType.Clock,
|
||||
isPublic: false,
|
||||
colour: 'colour',
|
||||
timeWarning: 1,
|
||||
timeDanger: 2,
|
||||
custom: {
|
||||
lighting: '3',
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges()', () => {
|
||||
it('identifies objects with new values', () => {
|
||||
const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
|
||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||
expect(hasChanges(existing, newEvent)).toBe(true);
|
||||
});
|
||||
it('identifies objects with all same values', () => {
|
||||
const newEvent = { id: '1', title: 'title' } as OntimeEvent;
|
||||
const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
|
||||
expect(hasChanges(existing, newEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateDayOffset', () => {
|
||||
it('returns 0 if there is no previous event', () => {
|
||||
expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import { makeRundown } from '../../../api-data/rundown/__mocks__/rundown.mocks.js';
|
||||
import { getPreviousId } from '../rundownUtils.js';
|
||||
|
||||
// Mock cache module
|
||||
vi.mock('../rundownCache.js', () => ({
|
||||
getEventOrder: () => ({
|
||||
flatOrder: ['a', 'b', 'c', 'd'],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('getPreviousId', () => {
|
||||
const rundown = makeRundown({
|
||||
flatOrder: ['a', 'b', 'c', 'd'],
|
||||
});
|
||||
|
||||
it('returns afterId if provided', () => {
|
||||
expect(getPreviousId('b')).toBe('b');
|
||||
expect(getPreviousId(rundown, 'b')).toBe('b');
|
||||
});
|
||||
|
||||
it('returns the previous id before beforeId if provided', () => {
|
||||
expect(getPreviousId(undefined, 'c')).toBe('b');
|
||||
expect(getPreviousId(rundown, undefined, 'c')).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined if neither afterId nor beforeId is provided', () => {
|
||||
expect(getPreviousId()).toBeUndefined();
|
||||
expect(getPreviousId(rundown)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns undefined if beforeId is not found', () => {
|
||||
expect(getPreviousId(undefined, 'z')).toBeUndefined();
|
||||
expect(getPreviousId(rundown, undefined, 'z')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Rundown, EntryId, isOntimeDelay, isOntimeEvent, OntimeEvent } from 'ontime-types';
|
||||
import { deleteAtIndex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Applies delay from given event ID, deletes the delay event after
|
||||
* Mutates the given rundown
|
||||
* @throws if event ID not found or is not a delay
|
||||
*/
|
||||
export function apply(delayId: EntryId, rundown: Rundown): Rundown {
|
||||
const delayEvent = rundown.entries[delayId];
|
||||
|
||||
if (!delayEvent || !isOntimeDelay(delayEvent)) {
|
||||
throw new Error('Given delay ID not found');
|
||||
}
|
||||
|
||||
const delayIndex = rundown.order.findIndex((entryId) => entryId === delayId);
|
||||
|
||||
// if the delay is empty, or the last element
|
||||
// we can just delete it with no further operations
|
||||
if (delayEvent.duration === 0 || delayIndex === rundown.order.length - 1) {
|
||||
delete rundown.entries[delayId];
|
||||
rundown.order = deleteAtIndex(delayIndex, rundown.order);
|
||||
return rundown;
|
||||
}
|
||||
|
||||
/**
|
||||
* We apply the delay to the rundown
|
||||
* This logic is mostly in sync with rundownCache.generate
|
||||
* The difference is that here it will become part of the schedule,
|
||||
* so we cant leave the work for the generate function
|
||||
*/
|
||||
let delayValue = delayEvent.duration;
|
||||
let lastEntry: OntimeEvent | null = null;
|
||||
let isFirstEvent = true;
|
||||
|
||||
for (let i = delayIndex + 1; i < rundown.order.length; i++) {
|
||||
const currentId = rundown.order[i];
|
||||
const currentEntry = rundown.entries[currentId];
|
||||
|
||||
// we don't do operation on other event types
|
||||
if (!isOntimeEvent(currentEntry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// we need to remove the link in the first event to maintain the gap
|
||||
let shouldUnlink = isFirstEvent;
|
||||
isFirstEvent = false;
|
||||
|
||||
// if the event is not linked, we try and maintain gaps
|
||||
if (lastEntry !== null) {
|
||||
// when applying negative delays, we need to unlink the event
|
||||
// if the previous event was fully consumed by the delay
|
||||
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
|
||||
shouldUnlink = true;
|
||||
}
|
||||
|
||||
if (currentEntry.gap > 0) {
|
||||
delayValue = Math.max(delayValue - currentEntry.gap, 0);
|
||||
}
|
||||
|
||||
if (delayValue === 0) {
|
||||
// we can bail from continuing if there are no further delays to apply
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// save the current entry before making mutations on its values
|
||||
lastEntry = { ...currentEntry };
|
||||
|
||||
if (shouldUnlink) {
|
||||
currentEntry.linkStart = false;
|
||||
shouldUnlink = false;
|
||||
}
|
||||
|
||||
// event times move up by the delay value
|
||||
// we dont update the delay value since we would need to iterate through the entire dataset
|
||||
// this is handled by the rundownCache.generate function
|
||||
currentEntry.timeStart = Math.max(0, currentEntry.timeStart + delayValue);
|
||||
currentEntry.timeEnd = Math.max(currentEntry.duration, currentEntry.timeEnd + delayValue);
|
||||
currentEntry.revision += 1;
|
||||
}
|
||||
|
||||
delete rundown.entries[delayId];
|
||||
rundown.order = deleteAtIndex(delayIndex, rundown.order);
|
||||
rundown.revision += 1;
|
||||
|
||||
return rundown;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { CustomFieldLabel, EntryId, MaybeNumber } from 'ontime-types';
|
||||
|
||||
export type RundownMetadata = {
|
||||
totalDelay: number;
|
||||
totalDuration: number;
|
||||
totalDays: number;
|
||||
firstStart: MaybeNumber;
|
||||
lastEnd: MaybeNumber;
|
||||
|
||||
playableEventOrder: EntryId[]; // flat order of playable events
|
||||
timedEventOrder: EntryId[]; // flat order of timed events
|
||||
flatEntryOrder: EntryId[]; // flat order of entries
|
||||
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
* since we can clear the custom fields from every event where they are used
|
||||
*/
|
||||
assignedCustomFields: Record<CustomFieldLabel, string[]>;
|
||||
};
|
||||
@@ -7,27 +7,16 @@ import {
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
OntimeBlock,
|
||||
OntimeEvent,
|
||||
OntimeEntry,
|
||||
Rundown,
|
||||
RundownEntries,
|
||||
OntimeDelay,
|
||||
} from 'ontime-types';
|
||||
import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils';
|
||||
import { generateId, insertAtIndex, customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js';
|
||||
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { apply } from './delayUtils.js';
|
||||
import {
|
||||
cloneBlock,
|
||||
cloneEntry,
|
||||
hasChanges,
|
||||
isDataStale,
|
||||
makeRundownMetadata,
|
||||
type ProcessedRundownMetadata,
|
||||
} from './rundownCache.utils.js';
|
||||
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
import { makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
|
||||
|
||||
let currentRundownId: EntryId = '';
|
||||
let currentRundown: Rundown = {
|
||||
@@ -373,370 +362,6 @@ export function add({ rundown, afterId, parent, entry }: AddArgs): Required<Muta
|
||||
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
|
||||
}
|
||||
|
||||
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
|
||||
/**
|
||||
* Remove entries in a rundown
|
||||
* It handles element relationships specifically when dealing with nested items
|
||||
* - when removing a nested item, remove the reference from the parent block
|
||||
* - when removing a block, remove all nested items
|
||||
*/
|
||||
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
|
||||
/**
|
||||
* changelist will hold a list of entries that need to be removed
|
||||
* it will then be returned to the caller as a list of actually deleted entries
|
||||
*/
|
||||
const changeList: EntryId[] = [];
|
||||
|
||||
for (let i = 0; i < eventIds.length; i++) {
|
||||
const entry = rundown.entries[eventIds[i]];
|
||||
// add the top level entry to the changeList
|
||||
changeList.push(entry.id);
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
// for ontime blocks, we need to iterate through the children and delete them
|
||||
changeList.concat([...entry.events]);
|
||||
} else if (entry.parent) {
|
||||
// at this point, we are handling entries inside a block, so we need to remove the references
|
||||
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
|
||||
const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]);
|
||||
|
||||
// we call a mutation to the parent event to
|
||||
// - remove this entry from the events
|
||||
// - reduce the children count
|
||||
edit({
|
||||
rundown,
|
||||
eventId: entry.parent,
|
||||
patch: {
|
||||
events: parentEvents,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// delete all entries in the changeList
|
||||
for (let i = 0; i < changeList.length; i++) {
|
||||
const entryId = changeList[i];
|
||||
rundown.order = rundown.order.filter((id) => id !== entryId);
|
||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
|
||||
delete rundown.entries[entryId];
|
||||
}
|
||||
|
||||
const didMutate = changeList.length > 0;
|
||||
if (didMutate) setIsStale();
|
||||
return { newRundown: rundown, didMutate, changeList };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all entries of a rundown
|
||||
*/
|
||||
export function removeAll(): MutatingReturn {
|
||||
setIsStale();
|
||||
return {
|
||||
newRundown: {
|
||||
id: '',
|
||||
title: '',
|
||||
order: [],
|
||||
flatOrder: [],
|
||||
entries: {},
|
||||
revision: 0,
|
||||
},
|
||||
didMutate: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for patching an existing event with new data
|
||||
*/
|
||||
function makeEvent<T extends OntimeEntry>(eventFromRundown: T, patch: Partial<T>): T {
|
||||
if (isOntimeEvent(eventFromRundown)) {
|
||||
const newEvent = createPatch(eventFromRundown, patch as Partial<OntimeEvent>);
|
||||
newEvent.revision++;
|
||||
return newEvent as T;
|
||||
}
|
||||
if (isOntimeBlock(eventFromRundown)) {
|
||||
const newEvent: OntimeBlock = { ...eventFromRundown, ...patch };
|
||||
newEvent.revision++;
|
||||
return newEvent as T;
|
||||
}
|
||||
|
||||
return { ...eventFromRundown, ...patch } as T;
|
||||
}
|
||||
|
||||
type EditArgs = MutationParams<{ eventId: EntryId; patch: Partial<OntimeEntry> }>;
|
||||
/**
|
||||
* Apply patch to an entry with given id
|
||||
*/
|
||||
export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingReturn> {
|
||||
const entry = rundown.entries[eventId];
|
||||
if (!entry) {
|
||||
// there should be no reason for the entry not to be found
|
||||
// check if it exists in the rundown order
|
||||
rundown.order = rundown.order.filter((id) => id !== eventId);
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
// we cannot allow patching to a different type
|
||||
if (patch?.type && entry.type !== patch.type) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
|
||||
// if nothing changed, nothing to do
|
||||
if (!hasChanges(entry, patch)) {
|
||||
return { newRundown: rundown, changeList: [eventId], newEvent: entry, didMutate: false };
|
||||
}
|
||||
|
||||
const newEvent = makeEvent(entry, patch);
|
||||
rundown.entries[newEvent.id] = newEvent;
|
||||
|
||||
// check whether the data warrants recalculation of cache
|
||||
const makeStale = isDataStale(patch);
|
||||
|
||||
if (makeStale) {
|
||||
setIsStale();
|
||||
} else {
|
||||
rundown.entries[newEvent.id] = newEvent;
|
||||
}
|
||||
|
||||
return { newRundown: rundown, changeList: [newEvent.id], newEvent, didMutate: true };
|
||||
}
|
||||
|
||||
type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial<OntimeEntry> }>;
|
||||
/**
|
||||
* Apply patch to multiple entries
|
||||
*/
|
||||
export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): MutatingReturn {
|
||||
for (const eventId of eventIds) {
|
||||
edit({ rundown, eventId, patch });
|
||||
}
|
||||
return { newRundown: rundown, didMutate: true };
|
||||
}
|
||||
|
||||
type ReorderArgs = MutationParams<{
|
||||
entryId: EntryId;
|
||||
destinationId: EntryId;
|
||||
order: 'before' | 'after' | 'insert';
|
||||
}>;
|
||||
/**
|
||||
* Moves an event to a new position in the rundown
|
||||
* Handles moving across root orders (a block order and top level order)
|
||||
* @throws if entryId or destinationId not found
|
||||
* @throws if trying to insert an event into a block inside another block
|
||||
*/
|
||||
export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required<MutatingReturn> {
|
||||
const eventFrom = rundown.entries[entryId];
|
||||
const eventTo = rundown.entries[destinationId];
|
||||
|
||||
if (!eventFrom || !eventTo) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null;
|
||||
const toParent = (() => {
|
||||
if (isOntimeBlock(eventTo)) {
|
||||
if (order === 'insert') {
|
||||
return eventTo.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return eventTo.parent ?? null;
|
||||
})();
|
||||
|
||||
if (!isOntimeBlock(eventFrom)) {
|
||||
eventFrom.parent = toParent;
|
||||
}
|
||||
|
||||
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events;
|
||||
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events;
|
||||
|
||||
const fromIndex = sourceArray.indexOf(entryId);
|
||||
const toIndex = (() => {
|
||||
const baseIndex = destinationArray.indexOf(destinationId);
|
||||
if (order === 'before') return baseIndex;
|
||||
// only add one if we are moving down
|
||||
if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1);
|
||||
// for insert we add in the end of the array
|
||||
return destinationArray.length;
|
||||
})();
|
||||
|
||||
// Remove from source array
|
||||
sourceArray.splice(fromIndex, 1);
|
||||
// Insert into destination array
|
||||
destinationArray.splice(toIndex, 0, entryId);
|
||||
|
||||
// changelist is derived from the flat order
|
||||
const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length);
|
||||
|
||||
return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true };
|
||||
}
|
||||
|
||||
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
|
||||
/**
|
||||
* Apply a delay
|
||||
* Mutates the given rundown
|
||||
*/
|
||||
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
|
||||
apply(delayId, rundown);
|
||||
setIsStale();
|
||||
return { newRundown: rundown, didMutate: true };
|
||||
}
|
||||
|
||||
type CloneEntryArgs = MutationParams<{ entryId: EntryId }>;
|
||||
/**
|
||||
* Apply a delay
|
||||
* Mutates the given rundown
|
||||
*/
|
||||
export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
const newBlock = cloneBlock(entry, getUniqueId());
|
||||
const nestedIds: EntryId[] = [];
|
||||
|
||||
for (let i = 0; i < entry.events.length; i++) {
|
||||
const nestedEntryId = entry.events[i];
|
||||
const nestedEntry = rundown.entries[nestedEntryId];
|
||||
if (!nestedEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// clone the event and assign it to the new block
|
||||
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId());
|
||||
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id;
|
||||
|
||||
nestedIds.push(newNestedEntry.id);
|
||||
// we immediately insert the nested entries into the rundown
|
||||
rundown.entries[newNestedEntry.id] = newNestedEntry;
|
||||
}
|
||||
// indexes + 1 since we are inserting after the cloned block
|
||||
const atIndex = rundown.order.indexOf(entryId) + 1;
|
||||
// we need to find the index of the last entry
|
||||
const lastNestedIdInOriginal = entry.events.at(-1) ?? '0';
|
||||
const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1;
|
||||
|
||||
newBlock.events = nestedIds;
|
||||
newBlock.title = `${entry.title || 'Untitled'} (copy)`;
|
||||
|
||||
rundown.entries[newBlock.id] = newBlock;
|
||||
rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order);
|
||||
rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder);
|
||||
|
||||
return { newRundown: rundown, didMutate: true, newEvent: newBlock };
|
||||
} else {
|
||||
return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) });
|
||||
}
|
||||
}
|
||||
|
||||
type UngroupArgs = MutationParams<{ blockId: EntryId }>;
|
||||
/**
|
||||
* Deletes a block and moves all its children to the top level order
|
||||
* Mutates the given rundown
|
||||
* @throws if block ID not found
|
||||
*/
|
||||
export function ungroup({ rundown, blockId }: UngroupArgs): MutatingReturn {
|
||||
const block = rundown.entries[blockId];
|
||||
if (!isOntimeBlock(block)) {
|
||||
throw new Error('Block with ID not found');
|
||||
}
|
||||
|
||||
// get the events from the block and merge them into the order where the block was
|
||||
const nestedEvents = block.events;
|
||||
const blockIndex = rundown.order.indexOf(blockId);
|
||||
rundown.order.splice(blockIndex, 1, ...nestedEvents);
|
||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId);
|
||||
|
||||
// delete block from entries and remove its reference from the child events
|
||||
delete rundown.entries[blockId];
|
||||
for (let i = 0; i < nestedEvents.length; i++) {
|
||||
const eventId = nestedEvents[i];
|
||||
const entry = rundown.entries[eventId];
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
(entry as OntimeEvent | OntimeDelay).parent = null;
|
||||
}
|
||||
|
||||
return { newRundown: rundown, didMutate: true };
|
||||
}
|
||||
|
||||
type GroupArgs = MutationParams<{ entryIds: EntryId[] }>;
|
||||
/**
|
||||
* Groups a list of entries into a block
|
||||
* It ensures that the entries get reassigned parent and the block gets a list of events
|
||||
* The block will be created at the index of the first event in the order, not at the lowest index
|
||||
* Mutates the given rundown
|
||||
* @throws if any of the entries is a block
|
||||
* @throws if any of the entries is not found
|
||||
*/
|
||||
export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn {
|
||||
const block = createBlock({ id: getUniqueId() });
|
||||
|
||||
const nestedEvents: EntryId[] = [];
|
||||
let firstIndex = -1;
|
||||
for (let i = 0; i < entryIds.length; i++) {
|
||||
const entryId = entryIds[i];
|
||||
const entry = rundown.entries[entryId];
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (isOntimeBlock(entry)) {
|
||||
throw new Error('Cannot group a block');
|
||||
}
|
||||
|
||||
if (entry.parent !== null) {
|
||||
throw new Error('Entry already has a parent');
|
||||
}
|
||||
|
||||
// the block will be created at the first selected event position
|
||||
// note that this is not the lowest index
|
||||
if (firstIndex === -1) {
|
||||
firstIndex = rundown.flatOrder.indexOf(entryId);
|
||||
}
|
||||
|
||||
nestedEvents.push(entryId);
|
||||
entry.parent = block.id;
|
||||
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
|
||||
rundown.order = rundown.order.filter((id) => id !== entryId);
|
||||
}
|
||||
|
||||
block.events = nestedEvents;
|
||||
const insertIndex = Math.max(0, firstIndex);
|
||||
// we have filtered the items from the order
|
||||
// we will insert them now, with only the block at top level ...
|
||||
rundown.order = insertAtIndex(insertIndex, block.id, rundown.order);
|
||||
/// ... and the nested elements after the block in the flat order
|
||||
rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder);
|
||||
rundown.entries[block.id] = block;
|
||||
|
||||
return { newRundown: rundown, didMutate: true };
|
||||
}
|
||||
|
||||
type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>;
|
||||
/**
|
||||
* Swap two entries
|
||||
*/
|
||||
export function swap({ rundown, fromId, toId }: SwapArgs): MutatingReturn {
|
||||
const fromEvent = rundown.entries[fromId];
|
||||
const toEvent = rundown.entries[toId];
|
||||
|
||||
if (!isOntimeEvent(fromEvent) || !isOntimeEvent(toEvent)) {
|
||||
throw new Error('Swap only available for OntimeEvents');
|
||||
}
|
||||
|
||||
const [newFrom, newTo] = swapEventData(fromEvent, toEvent);
|
||||
|
||||
rundown.entries[fromId] = newFrom;
|
||||
rundown.entries[toId] = newTo;
|
||||
newFrom.revision++;
|
||||
newTo.revision++;
|
||||
|
||||
setIsStale();
|
||||
return { newRundown: rundown, didMutate: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for invalidating service cache if a custom field is used
|
||||
*/
|
||||
|
||||
@@ -3,19 +3,16 @@ import {
|
||||
CustomFieldLabel,
|
||||
CustomFields,
|
||||
OntimeEntry,
|
||||
OntimeBaseEvent,
|
||||
EntryId,
|
||||
isOntimeEvent,
|
||||
isPlayableEvent,
|
||||
isOntimeDelay,
|
||||
PlayableEvent,
|
||||
RundownEntries,
|
||||
OntimeDelay,
|
||||
OntimeBlock,
|
||||
} from 'ontime-types';
|
||||
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import type { RundownMetadata } from '../../api-data/rundown/rundown.types.js';
|
||||
|
||||
/**
|
||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||
@@ -65,49 +62,6 @@ export function handleCustomField(
|
||||
}
|
||||
}
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum RegenerateWhitelist {
|
||||
'id',
|
||||
'cue',
|
||||
'title',
|
||||
'note',
|
||||
'endAction',
|
||||
'timerType',
|
||||
'countToEnd',
|
||||
'isPublic',
|
||||
'colour',
|
||||
'timeWarning',
|
||||
'timeDanger',
|
||||
'custom',
|
||||
'triggers',
|
||||
}
|
||||
|
||||
/**
|
||||
* given a patch, returns whether all keys are whitelisted
|
||||
*/
|
||||
export function isDataStale(patch: Partial<OntimeEntry>): boolean {
|
||||
return Object.keys(patch).some(willCauseRegeneration);
|
||||
}
|
||||
|
||||
/**
|
||||
* given a key, returns whether it is whitelisted
|
||||
*/
|
||||
export function willCauseRegeneration(key: string): boolean {
|
||||
return !(key in RegenerateWhitelist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a patch to that event checks whether there are actual changes to the dataset
|
||||
* @param existingEvent
|
||||
* @param newEvent
|
||||
* @returns
|
||||
*/
|
||||
export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent: Partial<T>): boolean {
|
||||
return Object.keys(newEvent).some(
|
||||
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for calculating if the current events should have a day offset
|
||||
* @param current the current event under test
|
||||
@@ -149,6 +103,10 @@ export type ProcessedRundownMetadata = RundownMetadata & {
|
||||
previousEntry: OntimeEntry | null; // The entry processed in the previous iteration
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create a rundown metadata processor
|
||||
* @returns {process, getMetadata} process() - processes entries in order | getMetadata() -> returns the current metadata
|
||||
*/
|
||||
export function makeRundownMetadata(customFields: CustomFields, customFieldChangelog: Record<string, string>) {
|
||||
let rundownMeta: ProcessedRundownMetadata = {
|
||||
totalDelay: 0,
|
||||
@@ -185,6 +143,9 @@ export function makeRundownMetadata(customFields: CustomFields, customFieldChang
|
||||
return { process, getMetadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single entry and updates the rundown metadata
|
||||
*/
|
||||
function processEntry<T extends OntimeEntry>(
|
||||
rundownMetadata: ProcessedRundownMetadata,
|
||||
customFields: CustomFields,
|
||||
@@ -288,37 +249,3 @@ function processEntry<T extends OntimeEntry>(
|
||||
|
||||
return { processedData, processedEntry: currentEntry };
|
||||
}
|
||||
|
||||
export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent {
|
||||
const newEntry = structuredClone(entry);
|
||||
newEntry.id = newId;
|
||||
newEntry.revision = 0;
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
|
||||
const newEntry = structuredClone(entry);
|
||||
newEntry.id = newId;
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
|
||||
const newEntry = structuredClone(entry);
|
||||
newEntry.id = newId;
|
||||
|
||||
// in blocks, we need to remove the events references
|
||||
newEntry.events = [];
|
||||
newEntry.revision = 0;
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
|
||||
if (isOntimeEvent(entry)) {
|
||||
return cloneEvent(entry, newId) as T;
|
||||
} else if (isOntimeDelay(entry)) {
|
||||
return cloneDelay(entry, newId) as T;
|
||||
} else if (entry.type === 'block') {
|
||||
return cloneBlock(entry as OntimeBlock, newId) as T;
|
||||
}
|
||||
throw new Error(`Unsupported entry type for cloning: ${entry}`);
|
||||
}
|
||||
|
||||
@@ -8,21 +8,16 @@ import {
|
||||
ProjectRundowns,
|
||||
} from 'ontime-types';
|
||||
|
||||
import * as cache from './rundownCache.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
|
||||
/**
|
||||
* returns entire unfiltered rundown
|
||||
*/
|
||||
export function getCurrentRundown(): Rundown {
|
||||
return cache.getCurrentRundown();
|
||||
}
|
||||
import * as cache from './rundownCache.js';
|
||||
|
||||
/**
|
||||
* returns the the project rundown and the order arrays
|
||||
*/
|
||||
export function getRundownData() {
|
||||
return {
|
||||
rundown: cache.getCurrentRundown(),
|
||||
rundown: getCurrentRundown(),
|
||||
rundownOrder: cache.getEventOrder(),
|
||||
};
|
||||
}
|
||||
@@ -178,17 +173,16 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string):
|
||||
* Receives an insertion order and returns the reference to an event ID
|
||||
* after which we will insert the new event
|
||||
*/
|
||||
export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined {
|
||||
export function getPreviousId(rundown: Rundown, afterId?: EntryId, beforeId?: EntryId): EntryId | null {
|
||||
if (afterId) {
|
||||
return afterId;
|
||||
}
|
||||
|
||||
if (beforeId) {
|
||||
const flatOrder = cache.getEventOrder().flatOrder;
|
||||
const atIndex = flatOrder.findIndex((id) => id === beforeId);
|
||||
if (atIndex < 1) return undefined;
|
||||
return flatOrder[atIndex - 1];
|
||||
const atIndex = rundown.flatOrder.findIndex((id) => id === beforeId);
|
||||
if (atIndex < 1) return null;
|
||||
return rundown.flatOrder[atIndex - 1];
|
||||
}
|
||||
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user