refactor: implement operations on nested events

This commit is contained in:
Carlos Valente
2025-04-17 09:36:22 +02:00
parent ba1292141b
commit ed7f8a0aa2
10 changed files with 257 additions and 139 deletions
+43 -10
View File
@@ -4,6 +4,7 @@ import {
EntryId, EntryId,
isOntimeEvent, isOntimeEvent,
MaybeString, MaybeString,
OntimeBlock,
OntimeEntry, OntimeEntry,
OntimeEvent, OntimeEvent,
Rundown, Rundown,
@@ -11,7 +12,7 @@ import {
TimeStrategy, TimeStrategy,
TransientEventPayload, TransientEventPayload,
} from 'ontime-types'; } from 'ontime-types';
import { dayInMs, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils'; import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { RUNDOWN } from '../api/constants'; import { RUNDOWN } from '../api/constants';
import { import {
@@ -71,6 +72,7 @@ export const useEntryActions = () => {
* @private * @private
*/ */
const _addEntryMutation = useMutation({ const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry, mutationFn: postAddEntry,
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN }); queryClient.invalidateQueries({ queryKey: RUNDOWN });
@@ -83,7 +85,7 @@ export const useEntryActions = () => {
*/ */
const addEntry = useCallback( const addEntry = useCallback(
async (entry: Partial<OntimeEntry>, options?: EventOptions) => { async (entry: Partial<OntimeEntry>, options?: EventOptions) => {
const newEntry: TransientEventPayload = { ...entry }; const newEntry: TransientEventPayload = { ...entry, id: generateId() };
// ************* CHECK OPTIONS specific to events // ************* CHECK OPTIONS specific to events
if (isOntimeEvent(newEntry)) { if (isOntimeEvent(newEntry)) {
@@ -188,6 +190,7 @@ export const useEntryActions = () => {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
order: previousData.order, order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown, entries: newRundown,
revision: -1, revision: -1,
}); });
@@ -355,6 +358,7 @@ export const useEntryActions = () => {
id: previousRundown.id, id: previousRundown.id,
title: previousRundown.title, title: previousRundown.title,
order: previousRundown.order, order: previousRundown.order,
flatOrder: previousRundown.flatOrder,
entries: newRundown, entries: newRundown,
revision: -1, revision: -1,
}); });
@@ -399,17 +403,14 @@ export const useEntryActions = () => {
if (previousData) { if (previousData) {
// optimistically update object // optimistically update object
const newOrder = previousData.order.filter((id) => !entryIds.includes(id)); const { entries, order, flatOrder } = optimisticDeleteEntries(entryIds, previousData);
const newRundown = { ...previousData.entries };
for (const eventId of entryIds) {
delete newRundown[eventId];
}
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
order: newOrder, order,
entries: newRundown, flatOrder,
entries,
revision: -1, revision: -1,
}); });
} }
@@ -462,8 +463,9 @@ export const useEntryActions = () => {
queryClient.setQueryData<Rundown>(RUNDOWN, { queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData?.id ?? 'default', id: previousData?.id ?? 'default',
title: previousData?.title ?? '', title: previousData?.title ?? '',
entries: {},
order: [], order: [],
flatOrder: [],
entries: {},
revision: -1, revision: -1,
}); });
@@ -542,6 +544,7 @@ export const useEntryActions = () => {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
order: newOrder, order: newOrder,
flatOrder: previousData.flatOrder,
entries: previousData.entries, entries: previousData.entries,
revision: -1, revision: -1,
}); });
@@ -613,6 +616,7 @@ export const useEntryActions = () => {
id: previousData.id, id: previousData.id,
title: previousData.title, title: previousData.title,
order: previousData.order, order: previousData.order,
flatOrder: previousData.flatOrder,
entries: newRundown, entries: newRundown,
revision: -1, revision: -1,
}); });
@@ -662,3 +666,32 @@ export const useEntryActions = () => {
updateCustomField, updateCustomField,
}; };
}; };
/**
* Utility to optimistically delete entries from client cache
*/
function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
const entries = { ...rundown.entries };
let order = [...rundown.order];
let flatOrder = [...rundown.flatOrder];
for (let i = 0; i < entryIds.length; i++) {
const entry = entries[entryIds[i]];
deleteEntry(entry);
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeEvent(entry) && entry.parent) {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.numEvents -= 1;
} else {
order = order.filter((id) => id !== entry.id);
}
delete entries[entry.id];
flatOrder = flatOrder.filter((id) => id !== entry.id);
}
return { entries, order, flatOrder };
}
+28 -23
View File
@@ -8,6 +8,7 @@ import {
type Rundown, type Rundown,
isOntimeBlock, isOntimeBlock,
isOntimeEvent, isOntimeEvent,
OntimeEntry,
Playback, Playback,
SupportedEvent, SupportedEvent,
} from 'ontime-types'; } from 'ontime-types';
@@ -93,7 +94,7 @@ export default function Rundown({ data }: RundownProps) {
); );
const insertAtId = useCallback( const insertAtId = useCallback(
(type: SupportedEvent, id: MaybeString, above = false) => { (patch: Partial<OntimeEntry> & { type: SupportedEvent }, id: MaybeString, above = false) => {
const options: EventOptions = const options: EventOptions =
id === null id === null
? {} ? {}
@@ -102,17 +103,10 @@ export default function Rundown({ data }: RundownProps) {
before: above ? id : undefined, before: above ? id : undefined,
}; };
if (type === SupportedEvent.Event) { if (!above && id) {
const newEvent = { options.lastEventId = id;
type: SupportedEvent.Event,
};
if (!above && id) {
options.lastEventId = id;
}
addEntry(newEvent, options);
} else {
addEntry({ type }, options);
} }
addEntry(patch, options);
}, },
[addEntry], [addEntry],
); );
@@ -208,14 +202,14 @@ export default function Rundown({ data }: RundownProps) {
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }], ['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }], ['alt + E', () => insertAtId({ type: SupportedEvent.Event }, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }], ['alt + shift + E', () => insertAtId({ type: SupportedEvent.Event }, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }], ['alt + B', () => insertAtId({ type: SupportedEvent.Block }, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }], ['alt + shift + B', () => insertAtId({ type: SupportedEvent.Block }, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }], ['alt + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }], ['alt + shift + D', () => insertAtId({ type: SupportedEvent.Delay }, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor)], ['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)], ['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
@@ -260,7 +254,7 @@ export default function Rundown({ data }: RundownProps) {
}; };
if (statefulEntries.length < 1) { if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />; return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEvent.Event }, cursor)} />;
} }
// 1. gather presentation options // 1. gather presentation options
@@ -292,15 +286,21 @@ export default function Rundown({ data }: RundownProps) {
return ( return (
<Fragment key={entry.id}> <Fragment key={entry.id}>
{isEditMode && (hasCursor || isFirst) && ( {isEditMode && (hasCursor || isFirst) && (
<QuickAddBlock showBlocks previousEventId={rundownMeta.previousEntryId} /> <QuickAddBlock previousEventId={rundownMeta.previousEntryId} parentBlock={null} />
)} )}
{isOntimeBlock(entry) ? ( {isOntimeBlock(entry) ? (
<BlockBlock data={entry} hasCursor={hasCursor}> <BlockBlock data={entry} hasCursor={hasCursor}>
{entry.events.length === 0 && ( {entry.events.length === 0 && (
<BlockEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} /> <BlockEmpty
handleAddNew={() => insertAtId({ type: SupportedEvent.Event, parent: entry.id }, entry.id)}
/>
)} )}
{entry.events.map((eventId, nestedIndex) => { {entry.events.map((eventId, nestedIndex) => {
const nestedEntry = entries[eventId]; const nestedEntry = entries[eventId];
if (!nestedEntry) {
return null;
}
const nestedRundownMeta = process(nestedEntry); const nestedRundownMeta = process(nestedEntry);
const isFirstInGroup = nestedIndex === 0; const isFirstInGroup = nestedIndex === 0;
const isLastInGroup = nestedIndex === entry.events.length - 1; const isLastInGroup = nestedIndex === entry.events.length - 1;
@@ -312,7 +312,10 @@ export default function Rundown({ data }: RundownProps) {
return ( return (
<Fragment key={nestedEntry.id}> <Fragment key={nestedEntry.id}>
{isEditMode && (hasNestedCursor || isFirstInGroup) && ( {isEditMode && (hasNestedCursor || isFirstInGroup) && (
<QuickAddBlock previousEventId={rundownMeta.previousEntryId} /> <QuickAddBlock
parentBlock={entry.id}
previousEventId={nestedRundownMeta.previousEntryId}
/>
)} )}
<div <div
@@ -342,7 +345,7 @@ export default function Rundown({ data }: RundownProps) {
</div> </div>
</div> </div>
{isEditMode && (hasNestedCursor || isLastInGroup) && ( {isEditMode && (hasNestedCursor || isLastInGroup) && (
<QuickAddBlock previousEventId={entry.id} /> <QuickAddBlock parentBlock={entry.id} previousEventId={nestedEntry.id} />
)} )}
</Fragment> </Fragment>
); );
@@ -371,7 +374,9 @@ export default function Rundown({ data }: RundownProps) {
</div> </div>
</div> </div>
)} )}
{isEditMode && (hasCursor || isLast) && <QuickAddBlock showBlocks previousEventId={entry.id} />} {isEditMode && (hasCursor || isLast) && (
<QuickAddBlock previousEventId={entry.id} parentBlock={null} />
)}
</Fragment> </Fragment>
); );
})} })}
@@ -5,6 +5,7 @@
margin: 0.25rem 0; margin: 0.25rem 0;
font-size: calc(1rem - 3px); font-size: calc(1rem - 3px);
margin-left: calc(2em + 0.5rem);
} }
.quickBtn { .quickBtn {
@@ -1,74 +1,69 @@
import { memo, useCallback, useRef } from 'react'; import { memo, useRef } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react'; import { Button } from '@chakra-ui/react';
import { MaybeString, SupportedEvent } from 'ontime-types'; import { MaybeString, SupportedEvent } from 'ontime-types';
import { useEntryActions } from '../../../common/hooks/useEntryAction'; import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { useEmitLog } from '../../../common/stores/logger';
import style from './QuickAddBlock.module.scss'; import style from './QuickAddBlock.module.scss';
interface QuickAddBlockProps { interface QuickAddBlockProps {
previousEventId: MaybeString; previousEventId: MaybeString;
showBlocks?: boolean; parentBlock: MaybeString;
} }
export default memo(QuickAddBlock); export default memo(QuickAddBlock);
function QuickAddBlock(props: QuickAddBlockProps) { function QuickAddBlock(props: QuickAddBlockProps) {
const { previousEventId, showBlocks } = props; const { previousEventId, parentBlock } = props;
const { addEntry } = useEntryActions(); const { addEntry } = useEntryActions();
const { emitError } = useEmitLog();
const doLinkPrevious = useRef<HTMLInputElement | null>(null); const doLinkPrevious = useRef<HTMLInputElement | null>(null);
const doPublic = useRef<HTMLInputElement | null>(null); const doPublic = useRef<HTMLInputElement | null>(null);
const handleCreateEvent = useCallback( const addEvent = () => {
(eventType: SupportedEvent) => { addEntry(
switch (eventType) { {
case 'event': { type: SupportedEvent.Event,
const defaultPublic = doPublic?.current?.checked; parent: parentBlock ?? null,
const linkPrevious = doLinkPrevious?.current?.checked; },
{
after: previousEventId,
defaultPublic: doPublic?.current?.checked,
lastEventId: previousEventId,
linkPrevious: doLinkPrevious?.current?.checked,
},
);
};
const newEvent = { type: SupportedEvent.Event }; const addDelay = () => {
const options = { addEntry(
after: previousEventId, // TODO(v4): add delays to blocks
defaultPublic, { type: SupportedEvent.Delay },
lastEventId: previousEventId, {
linkPrevious, lastEventId: previousEventId,
}; after: previousEventId,
addEntry(newEvent, options); },
break; );
} };
case 'delay': {
const options = { const addBlock = () => {
lastEventId: previousEventId, if (parentBlock !== null) {
after: previousEventId, return;
}; }
addEntry({ type: SupportedEvent.Delay }, options); addEntry(
break; { type: SupportedEvent.Block },
} {
case 'block': { lastEventId: previousEventId,
const options = { after: previousEventId,
lastEventId: previousEventId, },
after: previousEventId, );
}; };
addEntry({ type: SupportedEvent.Block }, options);
break;
}
default: {
emitError(`Cannot create unknown event type: ${eventType}`);
break;
}
}
},
[previousEventId, addEntry, emitError],
);
return ( return (
<div className={style.quickAdd}> <div className={style.quickAdd}>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Event)} onClick={addEvent}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -78,7 +73,7 @@ function QuickAddBlock(props: QuickAddBlockProps) {
Event Event
</Button> </Button>
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Delay)} onClick={addDelay}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -87,9 +82,9 @@ function QuickAddBlock(props: QuickAddBlockProps) {
> >
Delay Delay
</Button> </Button>
{showBlocks && ( {parentBlock === null && (
<Button <Button
onClick={() => handleCreateEvent(SupportedEvent.Block)} onClick={addBlock}
size='xs' size='xs'
variant='ontime-subtle-white' variant='ontime-subtle-white'
className={style.quickBtn} className={style.quickBtn}
@@ -29,7 +29,7 @@ export function makeRundownMetadata(selectedEventId: MaybeString) {
isNext: false, isNext: false,
isNextDay: false, isNextDay: false,
totalGap: 0, totalGap: 0,
isLinkedToLoaded: true, isLinkedToLoaded: false,
isLoaded: false, isLoaded: false,
}; };
@@ -56,6 +56,7 @@ function processEntry(
processedData.isLoaded = false; processedData.isLoaded = false;
processedData.previousEntryId = processedData.thisId; processedData.previousEntryId = processedData.thisId;
processedData.thisId = entry.id; processedData.thisId = entry.id;
processedData.previousEvent = processedData.latestEvent;
if (entry.id === selectedEventId) { if (entry.id === selectedEventId) {
processedData.isLoaded = true; processedData.isLoaded = true;
@@ -65,19 +66,18 @@ function processEntry(
if (isOntimeEvent(entry)) { if (isOntimeEvent(entry)) {
// event indexes are 1 based in UI // event indexes are 1 based in UI
processedData.eventIndex += 1; processedData.eventIndex += 1;
processedData.previousEvent = processedData.latestEvent;
if (isPlayableEvent(entry)) { if (isPlayableEvent(entry)) {
processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent); processedData.isNextDay = checkIsNextDay(entry, processedData.previousEvent);
processedData.totalGap += entry.gap;
if (!processedData.isPast) { if (!processedData.isPast && !processedData.isLoaded) {
processedData.totalGap += entry.gap;
/** /**
* isLinkToLoaded is a chain value that we maintain until we find an unlinked event * isLinkToLoaded is a chain value that we maintain until we
* or we find a countToEnd event * a) find an unlinked event
* b) find a countToEnd event
*/ */
processedData.isLinkedToLoaded = processedData.isLinkedToLoaded = entry.linkStart && !processedData.previousEvent?.countToEnd;
processedData.isLinkedToLoaded && entry.linkStart && !processedData.previousEvent?.countToEnd;
} }
if (isNewLatest(entry, processedData.previousEvent)) { if (isNewLatest(entry, processedData.previousEvent)) {
@@ -1,6 +1,5 @@
import { import {
CustomFields, CustomFields,
LogOrigin,
OntimeBlock, OntimeBlock,
OntimeDelay, OntimeDelay,
OntimeEvent, OntimeEvent,
@@ -17,12 +16,12 @@ import { getCueCandidate } from 'ontime-utils';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js'; import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { sendRefetch } from '../../adapters/websocketAux.js'; import { sendRefetch } from '../../adapters/websocketAux.js';
import { logger } from '../../classes/Logger.js';
import { createEvent } from '../../utils/parser.js'; import { createEvent } from '../../utils/parser.js';
import { updateRundownData } from '../../stores/runtimeState.js'; import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js'; import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
import { getInsertionPosition } from './rundownUtils.js';
type CompleteEntry<T> = type CompleteEntry<T> =
T extends Partial<OntimeEvent> T extends Partial<OntimeEvent>
@@ -40,10 +39,6 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
eventData: T, eventData: T,
afterId?: string, afterId?: string,
): CompleteEntry<T> { ): CompleteEntry<T> {
// TODO: could we keep the UI ID to avoid the flash on create?
// we discard any UI provided IDs and add our own
const id = cache.getUniqueId();
if (isOntimeEvent(eventData)) { if (isOntimeEvent(eventData)) {
const currentRundown = cache.getCurrentRundown(); const currentRundown = cache.getCurrentRundown();
return createEvent( return createEvent(
@@ -52,10 +47,13 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
) as CompleteEntry<T>; ) as CompleteEntry<T>;
} }
const id = eventData.id || cache.getUniqueId();
if (isOntimeDelay(eventData)) { if (isOntimeDelay(eventData)) {
return { ...delayDef, duration: eventData.duration ?? 0, id } as CompleteEntry<T>; 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)) { if (isOntimeBlock(eventData)) {
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>; return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
} }
@@ -67,41 +65,46 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
* creates a new event with given data * creates a new event with given data
*/ */
export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> { export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry> {
// if the user didnt provide an index, we add the event to start // 1. we allow the user to provide an ID, but make sure it is unique
let atIndex = 0; if (eventData?.id && cache.hasId(eventData.id)) {
let afterId: string | undefined = eventData?.after; throw new Error(`Event with ID ${eventData.id} already exists`);
}
if (afterId) { // 2. if the user provides a parent (inside a group), we make sure it exists
const previousIndex = cache.getIndexOf(afterId); let parent: EntryId | undefined;
if (previousIndex < 0) { if ('parent' in eventData && eventData.parent != null) {
logger.warning(LogOrigin.Server, `Could not find event with id ${afterId}`); if (!cache.hasId(eventData.parent)) {
} else { throw new Error(`Parent event with ID ${eventData.parent} not found`);
atIndex = previousIndex + 1;
} }
} else if (eventData?.before !== undefined) { parent = eventData.parent;
const previousIndex = cache.getIndexOf(eventData.before); }
if (previousIndex < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.before}`); // 3. if the user provides an after or before ID, we make sure it exists
} else { if (eventData?.after !== undefined) {
atIndex = previousIndex; if (!cache.hasId(eventData.after)) {
if (previousIndex > 0) { throw new Error(`Event with ID ${eventData.after} not found`);
afterId = cache.getIdOf(atIndex - 1); }
} }
if (eventData?.before !== undefined) {
if (!cache.hasId(eventData.before)) {
throw new Error(`Event with ID ${eventData.before} not found`);
} }
} }
// generate a fully formed event from the patch const { afterId, atIndex } = getInsertionPosition(parent, eventData?.after, eventData?.before);
const eventToAdd = generateEvent(eventData, afterId);
// generate a fully formed entry from the patch
const sanitisedEntry = generateEvent(eventData, afterId);
// modify rundown // modify rundown
const scopedMutation = cache.mutateCache(cache.add); const scopedMutation = cache.mutateCache(cache.add);
const { newEvent } = await scopedMutation({ atIndex, event: eventToAdd }); const { newEvent } = await scopedMutation({ atIndex, parent, entry: sanitisedEntry });
// notify runtime that rundown has changed // notify runtime that rundown has changed
updateRuntimeOnChange(); updateRuntimeOnChange();
// notify timer and external services of change // notify timer and external services of change
notifyChanges({ timer: [eventToAdd.id], external: true }); notifyChanges({ timer: [sanitisedEntry.id], external: true });
// we know this mutation returns an OntimeEntry // we know this mutation returns an OntimeEntry
return newEvent as OntimeEntry; return newEvent as OntimeEntry;
@@ -26,6 +26,7 @@ let currentRundown: Rundown = {
id: '', id: '',
title: '', title: '',
order: [], order: [],
flatOrder: [],
entries: {}, entries: {},
revision: 0, revision: 0,
}; };
@@ -101,6 +102,9 @@ export function generate(
// 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 currentEntryId = initialRundown.order[i]; const currentEntryId = initialRundown.order[i];
const currentEntry = initialRundown.entries[currentEntryId]; const currentEntry = initialRundown.entries[currentEntryId];
if (!currentEntry) {
continue;
}
const { processedEntry } = process(currentEntry, null); const { processedEntry } = process(currentEntry, null);
// if the event is a block, we process the nested entries // if the event is a block, we process the nested entries
@@ -115,6 +119,10 @@ export function generate(
for (let i = 0; i < processedEntry.events.length; i++) { for (let i = 0; i < processedEntry.events.length; i++) {
const nestedEntryId = processedEntry.events[i]; const nestedEntryId = processedEntry.events[i];
const nestedEntry = initialRundown.entries[nestedEntryId]; const nestedEntry = initialRundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process( const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry, nestedEntry,
processedEntry.id, processedEntry.id,
@@ -161,14 +169,22 @@ export function updateCache() {
// update the cache values // update the cache values
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data // eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
const { entries, order, previousEvent, latestEvent, ...metadata } = processedData; const { previousEvent, latestEvent, ...metadata } = processedData;
currentRundown.entries = entries; currentRundown.entries = metadata.entries;
currentRundown.order = order; currentRundown.order = metadata.order;
currentRundown.flatOrder = metadata.flatEventOrder;
rundownMetadata = metadata; rundownMetadata = metadata;
clearIsStale(); clearIsStale();
customFieldChangelog = {}; customFieldChangelog = {};
} }
/**
* Whether a given ID is exists in the current rundown
*/
export function hasId(id: EntryId): boolean {
return Object.hasOwn(currentRundown.entries, id);
}
/** Returns an ID guaranteed to be unique */ /** Returns an ID guaranteed to be unique */
export function getUniqueId(): string { export function getUniqueId(): string {
if (isStale) { if (isStale) {
@@ -177,19 +193,19 @@ export function getUniqueId(): string {
let id = ''; let id = '';
do { do {
id = generateId(); id = generateId();
} while (Object.hasOwn(currentRundown.entries, id)); } while (hasId(id));
return id; return id;
} }
/** Returns index of an event with a given id */ /** Returns index of an entry with a given id */
export function getIndexOf(eventId: EntryId) { export function getIndexOf(entryId: EntryId) {
if (isStale) { if (isStale) {
updateCache(); updateCache();
} }
return currentRundown.order.indexOf(eventId); return currentRundown.order.indexOf(entryId);
} }
/** Returns id of an event at a given index */ /** Returns id of an entry at a given index */
export function getIdOf(index: number) { export function getIdOf(index: number) {
if (isStale) { if (isStale) {
updateCache(); updateCache();
@@ -304,34 +320,60 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
return scopedMutation; return scopedMutation;
} }
type AddArgs = MutationParams<{ atIndex: number; event: OntimeEntry }>; type AddArgs = MutationParams<{ atIndex: number; parent?: EntryId; entry: OntimeEntry }>;
/** /**
* Add entry to rundown * Add entry to rundown
*/ */
export function add({ rundown, atIndex, event }: AddArgs): Required<MutatingReturn> { export function add({ rundown, atIndex, parent, entry }: AddArgs): Required<MutatingReturn> {
const newEvent: OntimeEntry = { ...event }; const newEntry: OntimeEntry = { ...entry };
rundown.entries[newEntry.id] = newEntry;
if (parent) {
const parentBlock = rundown.entries[parent] as OntimeBlock;
parentBlock.events = insertAtIndex(atIndex, newEntry.id, parentBlock.events);
} else {
rundown.order = insertAtIndex(atIndex, newEntry.id, rundown.order);
}
rundown.entries[newEvent.id] = newEvent;
rundown.order = insertAtIndex(atIndex, newEvent.id, rundown.order);
setIsStale(); setIsStale();
return { newRundown: rundown, newEvent, didMutate: true }; return { newRundown: rundown, newEvent: newEntry, didMutate: true };
} }
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>; type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
/** /**
* Remove entry to rundown * Remove entries in a rundown
*/ */
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn { export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
const previousLength = rundown.order.length; let didMutate = false;
rundown.order = rundown.order.filter((id) => !eventIds.includes(id));
for (const id of eventIds) { for (let i = 0; i < eventIds.length; i++) {
delete rundown.entries[id]; const entry = rundown.entries[eventIds[i]];
if (isOntimeEvent(entry) && entry.parent) {
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
edit({
rundown,
eventId: entry.parent,
patch: {
events: parentBlock.events.filter((id) => id !== eventIds[i]),
numEvents: parentBlock.events.length - 1,
},
});
parentBlock.events = parentBlock.events.filter((id) => id !== entry.id);
} else {
rundown.order = rundown.order.filter((id) => id !== eventIds[i]);
}
didMutate = true;
delete rundown.entries[eventIds[i]];
} }
const didMutate = rundown.order.length !== previousLength;
if (didMutate) setIsStale(); if (didMutate) setIsStale();
return { newRundown: rundown, didMutate }; return { newRundown: rundown, didMutate };
} }
/**
* Remove all entries of a rundown
*/
export function removeAll(): MutatingReturn { export function removeAll(): MutatingReturn {
setIsStale(); setIsStale();
return { return {
@@ -339,6 +381,7 @@ export function removeAll(): MutatingReturn {
id: '', id: '',
title: '', title: '',
order: [], order: [],
flatOrder: [],
entries: {}, entries: {},
revision: 0, revision: 0,
}, },
@@ -441,6 +484,7 @@ export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<M
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>; type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
/** /**
* Apply a delay * Apply a delay
* Mutates the given rundown
*/ */
export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn { export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn {
apply(delayId, rundown); apply(delayId, rundown);
@@ -6,6 +6,7 @@ import {
EntryId, EntryId,
RundownEntries, RundownEntries,
ProjectRundowns, ProjectRundowns,
OntimeBlock,
} from 'ontime-types'; } from 'ontime-types';
import * as cache from './rundownCache.js'; import * as cache from './rundownCache.js';
@@ -173,3 +174,38 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string):
} }
return rundowns[rundownId]; return rundowns[rundownId];
} }
export function getInsertionPosition(
parentId?: EntryId,
afterId?: EntryId,
beforeId?: EntryId,
): { atIndex: number; afterId: EntryId | undefined } {
if (afterId) {
const order = selectOrderList(parentId);
return {
atIndex: order.findIndex((id) => id === afterId) + 1,
afterId,
};
}
if (beforeId) {
const order = selectOrderList(parentId);
const atIndex = order.findIndex((id) => id === beforeId);
return {
atIndex,
afterId: order[atIndex - 1] ?? null,
};
}
return {
atIndex: 0,
afterId: undefined,
};
function selectOrderList(parentId?: EntryId) {
if (parentId) {
return (getEntryWithId(parentId) as OntimeBlock).events;
}
return cache.getEventOrder().order;
}
}
@@ -55,8 +55,8 @@ export type OntimeEvent = OntimeBaseEvent & {
timeDanger: number; timeDanger: number;
custom: EntryCustomFields; custom: EntryCustomFields;
triggers?: Trigger[]; triggers?: Trigger[];
// !==== RUNTIME METADATA ====! //
parent: EntryId | null; parent: EntryId | null;
// !==== RUNTIME METADATA ====! //
revision: number; revision: number;
delay: number; // calculated at runtime delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime dayOffset: number; // calculated at runtime
@@ -12,6 +12,7 @@ export type Rundown = {
id: string; id: string;
title: string; title: string;
order: EntryId[]; order: EntryId[];
flatOrder: EntryId[];
entries: RundownEntries; entries: RundownEntries;
revision: number; revision: number;
}; };