mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 09:23:51 +00:00
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:
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user