feat: milestones

This commit is contained in:
Carlos Valente
2025-07-07 15:37:54 +02:00
committed by Carlos Valente
parent a7ae8598db
commit f2913971db
26 changed files with 610 additions and 77 deletions
@@ -4,8 +4,6 @@ import {
ProjectRundowns,
Rundown,
OntimeEvent,
OntimeDelay,
OntimeBlock,
isOntimeEvent,
isOntimeDelay,
isOntimeBlock,
@@ -15,6 +13,7 @@ import {
PlayableEvent,
RundownEntries,
isPlayableEvent,
isOntimeMilestone,
} from 'ontime-types';
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
@@ -22,7 +21,7 @@ import { defaultRundown } from '../../models/dataModel.js';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import type { ErrorEmitter } from '../../utils/parserUtils.js';
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent } from './rundown.utils.js';
import { calculateDayOffset, cleanupCustomFields, createBlock, createEvent, createMilestone } from './rundown.utils.js';
import { RundownMetadata } from './rundown.types.js';
/**
@@ -93,7 +92,7 @@ export function parseRundown(
}
const id = entryId;
let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null;
let newEvent: OntimeEntry | null;
const nestedEntryIds: string[] = [];
if (isOntimeEvent(event)) {
@@ -108,13 +107,17 @@ export function parseRundown(
eventIndex += 1;
} else if (isOntimeDelay(event)) {
newEvent = { ...delayDef, duration: event.duration, id };
} else if (isOntimeMilestone(event)) {
newEvent = createMilestone({ ...event, id });
cleanupCustomFields(newEvent.custom, parsedCustomFields);
} else if (isOntimeBlock(event)) {
for (let i = 0; i < event.entries.length; i++) {
const nestedEventId = event.entries[i];
const nestedEvent = rundown.entries[nestedEventId];
let newNestedEvent: OntimeEntry | null = null;
if (isOntimeEvent(nestedEvent)) {
const newNestedEvent = createEvent(nestedEvent, eventIndex);
newNestedEvent = createEvent(nestedEvent, eventIndex);
// skip if event is invalid
if (newNestedEvent == null) {
emitError?.('Skipping event without payload');
@@ -123,11 +126,16 @@ export function parseRundown(
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
eventIndex += 1;
} else if (isOntimeDelay(nestedEvent)) {
newNestedEvent = { ...delayDef, duration: nestedEvent.duration, id };
} else if (isOntimeMilestone(nestedEvent)) {
newNestedEvent = createMilestone({ ...nestedEvent, id });
cleanupCustomFields(newNestedEvent.custom, parsedCustomFields);
}
if (newNestedEvent) {
nestedEntryIds.push(nestedEventId);
parsedRundown.entries[nestedEventId] = newNestedEvent;
}
if (newNestedEvent) {
nestedEntryIds.push(nestedEventId);
parsedRundown.entries[nestedEventId] = newNestedEvent;
}
}
@@ -151,7 +159,7 @@ export function parseRundown(
}
}
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.order.length} entries`);
console.log(`Imported rundown ${parsedRundown.title} with ${parsedRundown.flatOrder.length} entries`);
return parsedRundown;
}
@@ -247,7 +247,6 @@ export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'b
throw new Error('Event not found');
}
console.log('reorder', eventFrom.id, eventTo.id, order);
rundownMutation.reorder(rundown, eventFrom, eventTo, order);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
@@ -5,11 +5,13 @@ import {
isOntimeBlock,
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEntry,
OntimeEvent,
OntimeMilestone,
Rundown,
SupportedEntry,
TimeStrategy,
@@ -23,7 +25,12 @@ import {
validateTimes,
} from 'ontime-utils';
import { event as eventDef, block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import {
event as eventDef,
block as blockDef,
delay as delayDef,
milestone as milestoneDef,
} from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
import { RundownMetadata } from './rundown.types.js';
@@ -34,16 +41,16 @@ type CompleteEntry<T> =
? OntimeDelay
: T extends Partial<OntimeBlock>
? OntimeBlock
: never;
: T extends Partial<OntimeMilestone>
? OntimeMilestone
: never;
/**
* Generates a fully formed RundownEntry of the patch type
*/
export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock>>(
rundown: Rundown,
eventData: T,
afterId: EntryId | null,
): CompleteEntry<T> {
export function generateEvent<
T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeBlock> | Partial<OntimeMilestone>,
>(rundown: Rundown, eventData: T, afterId: EntryId | null): CompleteEntry<T> {
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>;
}
@@ -59,6 +66,10 @@ export function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDel
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
}
if (isOntimeMilestone(eventData)) {
return createMilestone({ ...eventData, id }) as CompleteEntry<T>;
}
throw new Error('Invalid event type');
}
@@ -136,25 +147,52 @@ export function createBlockPatch(originalBlock: OntimeBlock, patchBlock: Partial
};
}
export function createMilestonePatch(
originalMilestone: OntimeMilestone,
patchMilestone: Partial<OntimeMilestone>,
): OntimeMilestone {
if (Object.keys(patchMilestone).length === 0) {
return originalMilestone;
}
return {
id: originalMilestone.id,
type: SupportedEntry.Milestone,
cue: makeString(patchMilestone.cue ?? null, originalMilestone.cue),
title: makeString(patchMilestone.title, originalMilestone.title),
note: makeString(patchMilestone.note, originalMilestone.note),
colour: makeString(patchMilestone.colour, originalMilestone.colour),
revision: originalMilestone.revision,
custom: { ...originalMilestone.custom, ...patchMilestone.custom },
parent: originalMilestone.parent,
};
}
/**
* 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 {
export function applyPatchToEntry(eventFromRundown: OntimeEntry, patch: Partial<OntimeEntry>): OntimeEntry {
if (isOntimeEvent(eventFromRundown)) {
const newEvent = createEventPatch(eventFromRundown, patch as Partial<OntimeEvent>);
const newEvent = createEventPatch(eventFromRundown as OntimeEvent, patch as Partial<OntimeEvent>);
newEvent.revision++;
return newEvent as T;
return newEvent;
}
if (isOntimeBlock(eventFromRundown)) {
const newBlock: OntimeBlock = createBlockPatch(eventFromRundown, patch as Partial<OntimeBlock>);
const newBlock = createBlockPatch(eventFromRundown as OntimeBlock, patch as Partial<OntimeBlock>);
newBlock.revision++;
return newBlock as T;
return newBlock;
}
if (isOntimeMilestone(eventFromRundown)) {
const newMilestone = createMilestonePatch(eventFromRundown as OntimeMilestone, patch as Partial<OntimeMilestone>);
newMilestone.revision++;
return newMilestone;
}
// only delay is left
return { ...eventFromRundown, ...patch } as T;
return { ...eventFromRundown, ...patch } as OntimeDelay;
}
/**
@@ -205,6 +243,27 @@ export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
};
}
/**
* Creates a new milestone from an optional patch
*/
export function createMilestone(patch?: Partial<OntimeMilestone>): OntimeMilestone {
if (!patch) {
return { ...milestoneDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Milestone,
cue: patch.cue ?? '',
title: patch.title ?? '',
note: patch.note ?? '',
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
parent: patch.parent ?? null,
revision: 0,
};
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
@@ -317,6 +376,15 @@ export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
return newEntry;
}
/**
* Gathers business logic for how to clone an OntimeMilestone
*/
export function cloneMilestone(entry: OntimeMilestone, newId: EntryId): OntimeMilestone {
const newEntry = structuredClone(entry);
newEntry.id = newId;
return newEntry;
}
/**
* Gathers business logic for how to clone an OntimeBlock
*/
@@ -333,13 +401,15 @@ export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
/**
* Receives an entry and chooses the correct cloning strategy
*/
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId) as T;
return cloneEvent(entry, newId);
} else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId) as T;
} else if (entry.type === 'block') {
return cloneBlock(entry as OntimeBlock, newId) as T;
return cloneDelay(entry, newId);
} else if (isOntimeBlock(entry)) {
return cloneBlock(entry, newId);
} else if (isOntimeMilestone(entry)) {
return cloneMilestone(entry, newId);
}
throw new Error(`Unsupported entry type for cloning: ${entry}`);
}
@@ -2,7 +2,7 @@ import { body, param } from 'express-validator';
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
export const rundownPostValidator = [
body('type').isString().isIn(['event', 'delay', 'block']),
body('type').isString().isIn(['event', 'delay', 'block', 'milestone']),
body('after').optional().isString(),
body('before').optional().isString(),