Improve Clone (#1897)

* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
This commit is contained in:
Carlos Valente
2025-11-28 16:20:52 +01:00
committed by GitHub
parent a78586d2fa
commit 3f1f06f7c5
21 changed files with 321 additions and 250 deletions
@@ -1564,7 +1564,7 @@ describe('rundownMutation.swap()', () => {
});
describe('rundownMutation.clone()', () => {
it('clones an event and adds it to the rundown', () => {
it('clones at the top level of the rundown', () => {
const testRundown = makeRundown({
order: ['1'],
entries: {
@@ -1583,7 +1583,7 @@ describe('rundownMutation.clone()', () => {
});
});
it('clones an event inside a group and adds it to the rundown', () => {
it('clones an event inside a group', () => {
const testRundown = makeRundown({
order: ['1'],
entries: {
@@ -1621,6 +1621,55 @@ describe('rundownMutation.clone()', () => {
});
expect((testRundown.entries[newEntry.id] as OntimeGroup).entries[0]).not.toBe('1a');
});
it('clones an entry from a group inside another group', () => {
const testRundown = makeRundown({
order: ['group1', 'group2'],
entries: {
group1: makeOntimeGroup({ id: 'group1', entries: ['event1'] }),
group2: makeOntimeGroup({ id: 'group2', entries: ['event2'] }),
event1: makeOntimeEvent({ id: 'event1', cue: 'nested-event', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', cue: 'nested-event', parent: 'group2' }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['event2'], {
after: 'event1',
}) as OntimeEvent;
// new event is added to group
expect(testRundown.entries['group1']).toMatchObject({
entries: ['event1', newEntry.id],
});
// new references the parent group
expect(newEntry.parent).toBe('group1');
// the flat rundown remains unchanged
expect(testRundown.order).toStrictEqual(['group1', 'group2']);
});
it('clones an event and inserts it before another event', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { before: '2' });
// Verify the rundown order is updated correctly
expect(testRundown.order).toStrictEqual(['1', newEntry.id, '2']);
// Verify the cloned entry is added to the rundown
expect(testRundown.entries[newEntry.id]).toMatchObject({
type: SupportedEntry.Event,
cue: 'event1',
parent: null,
});
});
});
describe('rundownMutation.group()', () => {
@@ -13,6 +13,7 @@ import {
duplicateRundown,
getInsertAfterId,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -265,7 +266,7 @@ describe('getInsertAfterId()', () => {
});
describe('duplicateRundown', () => {
it("duplicates a given rundown", () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title);
@@ -275,10 +276,51 @@ describe('duplicateRundown', () => {
entries: expect.any(Object),
order: expect.any(Array),
flatOrder: expect.any(Array),
})
});
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
})
})
});
});
describe('makeDeepClone()', () => {
it('deep clones a group along with its nested entries', () => {
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
const rundown = makeRundown({
entries: {
group1,
event1: makeOntimeEvent({ id: 'event1', title: 'Event 1', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', title: 'Event 2', parent: 'group1' }),
},
order: ['group1'],
flatOrder: ['group1', 'event1', 'event2'],
});
const { newGroup, nestedEntries } = makeDeepClone(group1, rundown);
expect(newGroup).toMatchObject({
id: expect.any(String),
title: 'Group 1 (copy)',
entries: [expect.any(String), expect.any(String)],
revision: 0,
});
expect(newGroup.id).not.toEqual('group1');
expect(newGroup.entries.length).toEqual(group1.entries.length);
expect(nestedEntries).toMatchObject([
{
id: expect.any(String),
title: 'Event 1',
parent: newGroup.id,
revision: 0,
},
{
id: expect.any(String),
title: 'Event 2',
parent: newGroup.id,
revision: 0,
},
]);
});
});
+68 -27
View File
@@ -24,23 +24,25 @@ import {
OntimeEvent,
PatchWithId,
Rundown,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneGroup,
cloneEntry,
cloneSimpleRundownEntry,
createGroup,
deleteById,
doesInvalidateMetadata,
getInsertAfterId,
getUniqueId,
makeDeepClone,
} from './rundown.utils.js';
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
import { consoleError } from '../../utils/console.js';
/**
* The currently loaded rundown in cache
@@ -171,6 +173,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
@@ -439,40 +446,74 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
* Inserts a clone of the given entry into the rundown
* Handles cloning children if the entry is a group
*/
function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): OntimeEntry {
if (isOntimeGroup(entry)) {
const newGroup = cloneGroup(entry, getUniqueId(rundown));
const nestedIds: EntryId[] = [];
const { newGroup, nestedEntries } = makeDeepClone(entry, rundown);
for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.entries[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new group
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown));
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newGroup.id;
nestedIds.push(newNestedEntry.id);
// we immediately insert the nested entries into the rundown
rundown.entries[newNestedEntry.id] = newNestedEntry;
// insert all entries into the rundown
rundown.entries[newGroup.id] = newGroup;
for (let i = 0; i < nestedEntries.length; i++) {
const nestedEntry = nestedEntries[i];
rundown.entries[nestedEntry.id] = nestedEntry;
}
// indexes + 1 since we are inserting after the cloned group
const atIndex = rundown.order.indexOf(entry.id) + 1;
// by default we insert after the cloned element
let atIndex = rundown.order.indexOf(entry.id) + 1;
newGroup.entries = nestedIds;
newGroup.title = `${entry.title || 'Untitled'} (copy)`;
const referenceId = options?.after ?? options?.before;
if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
}
}
rundown.entries[newGroup.id] = newGroup;
// we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
return newGroup;
} else {
const parent: OntimeGroup | null = entry.parent ? (rundown.entries[entry.parent] as OntimeGroup) : null;
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent);
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
let parent: OntimeGroup | null = null;
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
return add(rundown, clonedEntry, after, parent);
}
}
@@ -28,6 +28,7 @@ import {
entryReorderValidator,
entrySwapValidator,
validateRundownMutation,
clonePostValidator,
} from './rundown.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -296,10 +297,14 @@ router.patch(
router.post(
'/:rundownId/clone/:id',
paramsWithId,
clonePostValidator,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await cloneEntry(req.params.id);
const rundown = await cloneEntry(req.params.id, {
before: req.body?.before,
after: req.body?.after,
});
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
@@ -14,11 +14,15 @@ import {
Rundown,
LogOrigin,
ProjectRundowns,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
import {
createTransaction,
@@ -29,9 +33,6 @@ import {
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
/**
* creates a new entry with given data
@@ -347,17 +348,18 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
/**
* Clones an entry, ensuring that all dependencies are preserved
* Handles cloning children if the entry is a group
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
export async function cloneEntry(entryId: EntryId, options: InsertOptions): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
throw new Error('Did not find event to clone');
throw new Error('Could not find entry to clone');
}
const newEntry = rundownMutation.clone(rundown, originalEntry);
const newEntry = rundownMutation.clone(rundown, originalEntry, options);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
@@ -373,7 +375,6 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
} else if (isOntimeDelay(newEntry)) {
notifyChanges(rundownMetadata, revision, { external: true });
}
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -396,20 +396,54 @@ export function cloneGroup(entry: OntimeGroup, newId: EntryId): OntimeGroup {
// in groups, we need to remove the events references
newEntry.entries = [];
newEntry.title = `${entry.title || 'Untitled'} (copy)`;
newEntry.revision = 0;
return newEntry;
}
/**
* Receives an entry and chooses the correct cloning strategy
* Clones a group and all its nested entries
*/
export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
export function makeDeepClone(
group: OntimeGroup,
rundown: Rundown,
): { newGroup: OntimeGroup; nestedEntries: OntimeEntry[] } {
const newGroupId = getUniqueId(rundown);
const newGroup = cloneGroup(group, newGroupId);
const nestedEntries: OntimeEntry[] = [];
const nestedEntryIds: EntryId[] = [];
for (let i = 0; i < group.entries.length; i++) {
const nestedEntryId = group.entries[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new group
const nestedEntryNewId = getUniqueId(rundown);
const newNestedEntry = cloneSimpleRundownEntry(nestedEntry, nestedEntryNewId);
(newNestedEntry as OntimeEvent | OntimeDelay | OntimeMilestone).parent = newGroup.id;
nestedEntryIds.push(nestedEntryNewId);
nestedEntries.push(newNestedEntry);
}
// update the new group with the nested entries
newGroup.entries = nestedEntryIds;
return { newGroup, nestedEntries };
}
/**
* Receives an entry and chooses the correct cloning strategy
* @throws if the source entry is unknown or a group
*/
export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId);
} else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId);
} else if (isOntimeGroup(entry)) {
return cloneGroup(entry, newId);
} else if (isOntimeMilestone(entry)) {
return cloneMilestone(entry, newId);
}
@@ -42,6 +42,13 @@ export const entryPostValidator = [
requestValidationFunction,
];
export const clonePostValidator = [
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
];
export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
export const entryBatchPutValidator = [