mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-08 08:53:51 +00:00
feat: add multi-copy paste support
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
groupEntries,
|
||||
initRundown,
|
||||
loadRundown,
|
||||
pasteEntries,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
entryPutValidator,
|
||||
entryReorderValidator,
|
||||
entrySwapValidator,
|
||||
pastePostValidator,
|
||||
rundownArrayOfIds,
|
||||
rundownPostValidator,
|
||||
validateRundownMutation,
|
||||
@@ -302,6 +304,29 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Pastes entries from a source rundown into the active rundown
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/paste',
|
||||
pastePostValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await pasteEntries({
|
||||
entryIds: req.body.entryIds,
|
||||
sourceRundownId: req.body.sourceRundownId,
|
||||
afterId: req.body.afterId,
|
||||
beforeId: req.body.beforeId,
|
||||
});
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a group out of a list of entries
|
||||
*/
|
||||
|
||||
@@ -334,6 +334,102 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pastes a list of entries into the active rundown
|
||||
* Supports cross-rundown paste within the same instance
|
||||
* @throws if any entry is not found in the source rundown
|
||||
*/
|
||||
export async function pasteEntries(payload: {
|
||||
entryIds: EntryId[];
|
||||
sourceRundownId: string;
|
||||
afterId?: EntryId;
|
||||
beforeId?: EntryId;
|
||||
}): Promise<Rundown> {
|
||||
const { entryIds, sourceRundownId, afterId, beforeId } = payload;
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// resolve source rundown
|
||||
const sourceRundown =
|
||||
sourceRundownId === rundown.id ? rundown : getDataProvider().getRundown(sourceRundownId);
|
||||
|
||||
// validate all entry IDs exist in source
|
||||
for (const entryId of entryIds) {
|
||||
if (!sourceRundown.entries[entryId]) {
|
||||
throw new Error(`Entry with ID ${entryId} not found in source rundown`);
|
||||
}
|
||||
}
|
||||
|
||||
// resolve insertion position
|
||||
let currentAfterId: EntryId | undefined;
|
||||
|
||||
if (afterId) {
|
||||
currentAfterId = afterId;
|
||||
} else if (beforeId) {
|
||||
// for "before" positioning, pass it to the first clone and then chain after
|
||||
currentAfterId = undefined;
|
||||
}
|
||||
|
||||
const newEntryIds: EntryId[] = [];
|
||||
|
||||
// sort entry IDs by source rundown flatOrder so paste preserves rundown order
|
||||
// regardless of the order they were selected (e.g. ctrl-click order)
|
||||
const validIds = entryIds.filter((id) => sourceRundown.entries[id]);
|
||||
const orderedIds = validIds.sort((a, b) => {
|
||||
const idxA = sourceRundown.flatOrder.indexOf(a);
|
||||
const idxB = sourceRundown.flatOrder.indexOf(b);
|
||||
return idxA - idxB;
|
||||
});
|
||||
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
const entryId = orderedIds[i];
|
||||
const sourceEntry = sourceRundown.entries[entryId];
|
||||
|
||||
// resolve position reference for this entry
|
||||
// when pasting a group and the reference is a child inside another group,
|
||||
// normalise to the parent group so the pasted group lands at the top level
|
||||
const options: InsertOptions = {};
|
||||
if (i === 0 && beforeId && !afterId) {
|
||||
options.before = normaliseGroupPosition(rundown, sourceEntry, beforeId);
|
||||
} else if (currentAfterId) {
|
||||
options.after = normaliseGroupPosition(rundown, sourceEntry, currentAfterId);
|
||||
}
|
||||
|
||||
const newEntry = rundownMutation.clone(rundown, sourceEntry, options);
|
||||
newEntryIds.push(newEntry.id);
|
||||
|
||||
// chain: each new entry becomes the afterId for the next
|
||||
currentAfterId = newEntry.id;
|
||||
}
|
||||
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
notifyChanges(rundownMetadata, revision, { timer: newEntryIds, external: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* When pasting a group, the position reference must be a top-level entry.
|
||||
* If the reference is a child inside another group, resolve to the parent
|
||||
* so the pasted group ends up at the correct position in rundown.order.
|
||||
*/
|
||||
function normaliseGroupPosition(rundown: Rundown, sourceEntry: OntimeEntry, referenceId: EntryId): EntryId {
|
||||
if (!isOntimeGroup(sourceEntry)) {
|
||||
return referenceId;
|
||||
}
|
||||
|
||||
const referenceEntry = rundown.entries[referenceId];
|
||||
if (referenceEntry && 'parent' in referenceEntry && referenceEntry.parent) {
|
||||
return referenceEntry.parent;
|
||||
}
|
||||
|
||||
return referenceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones an entry, ensuring that all dependencies are preserved
|
||||
* Handles cloning children if the entry is a group
|
||||
|
||||
@@ -83,4 +83,14 @@ export const rundownArrayOfIds = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const pastePostValidator = [
|
||||
body('entryIds').isArray().notEmpty(),
|
||||
body('entryIds.*').isString(),
|
||||
body('sourceRundownId').isString().notEmpty(),
|
||||
body('afterId').optional().isString(),
|
||||
body('beforeId').optional().isString(),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
Reference in New Issue
Block a user