feat: auto cue re-numbering (#2016)

* feat: update auto cue numbering

* feat: renumber from ui

* refactor: patchEntries is not used

* chore: format

* fix: correct cue at top of group

* fix: handle precision

* refactor dialog

* bump limit for performance time test

* extract type

* add class name to lable

* fix rebase

* refator: extract renumering logic

* chore: comments for getIntegerAndFraction function

* chore: add the for renumber mutation

* fix: fraction match precision

* refactor: small cleanup

* refactor: use more narrow validator

---------

Co-authored-by: alex-Arc <omnivox@LAPTOP-RC5SNBVV.localdomain>
This commit is contained in:
Alex Christoffer Rasmussen
2026-05-03 18:39:00 +02:00
committed by GitHub
parent ba1c3235b3
commit 1a08b39b8b
20 changed files with 554 additions and 144 deletions
@@ -98,7 +98,7 @@ describe('processRundown()', () => {
Object.keys(result?.entries ?? {}).length,
'events',
);
expect(t2 - t1).lessThan(100);
expect(t2 - t1).lessThan(120);
});
it('generates metadata from given rundown', () => {
@@ -851,6 +851,98 @@ describe('rundownMutation.removeAll()', () => {
});
});
describe('rundownMutation.renumber()', () => {
it('sets cues from integer start and increment with no fractional part', () => {
const rundown = makeRundown({
order: ['a', 'b', 'c'],
entries: {
a: makeOntimeEvent({ id: 'a', cue: 'old-a' }),
b: makeOntimeEvent({ id: 'b', cue: 'old-b' }),
c: makeOntimeEvent({ id: 'c', cue: 'old-c' }),
},
});
rundownMutation.renumber(
rundown,
['a', 'b', 'c'],
'Q',
{ integer: 10, faction: 0, precision: 0 },
{ integer: 2, faction: 0, precision: 0 },
);
expect((rundown.entries['a'] as OntimeEvent).cue).toBe('Q10');
expect((rundown.entries['b'] as OntimeEvent).cue).toBe('Q12');
expect((rundown.entries['c'] as OntimeEvent).cue).toBe('Q14');
});
it('pads fractional segment to maxPrecision and steps faction by increment', () => {
const rundown = makeRundown({
order: ['a', 'b', 'c', 'd'],
entries: {
a: makeOntimeEvent({ id: 'a' }),
b: makeOntimeEvent({ id: 'b' }),
c: makeOntimeEvent({ id: 'c' }),
d: makeOntimeEvent({ id: 'd' }),
},
});
rundownMutation.renumber(
rundown,
['a', 'b', 'c', 'd'],
'',
{ integer: 1, faction: 0, precision: 2 },
{ integer: 0, faction: 25, precision: 2 },
);
expect((rundown.entries['a'] as OntimeEvent).cue).toBe('1.00');
expect((rundown.entries['b'] as OntimeEvent).cue).toBe('1.25');
expect((rundown.entries['c'] as OntimeEvent).cue).toBe('1.50');
expect((rundown.entries['d'] as OntimeEvent).cue).toBe('1.75');
});
it('throws when an id is not an event', () => {
const rundown = makeRundown({
order: ['e', 'd'],
entries: {
e: makeOntimeEvent({ id: 'e' }),
d: makeOntimeDelay({ id: 'd' }),
},
});
expect(() =>
rundownMutation.renumber(
rundown,
['e', 'd'],
'X',
{ integer: 1, faction: 0, precision: 0 },
{ integer: 1, faction: 0, precision: 0 },
),
).toThrowError('A given id was not an event');
});
it('handles mixed precision', () => {
const rundown = makeRundown({
order: ['a', 'b', 'c', 'd'],
entries: {
a: makeOntimeEvent({ id: 'a' }),
b: makeOntimeEvent({ id: 'b' }),
c: makeOntimeEvent({ id: 'c' }),
d: makeOntimeEvent({ id: 'd' }),
},
});
const inc = { integer: 0, faction: 5, precision: 1 }; // 0.5
const start = { integer: 1, faction: 5, precision: 2 }; // 1.05
rundownMutation.renumber(rundown, ['a', 'b', 'c', 'd'], 'X', start, inc);
expect((rundown.entries['a'] as OntimeEvent).cue).toBe('X1.05');
expect((rundown.entries['b'] as OntimeEvent).cue).toBe('X1.55');
expect((rundown.entries['c'] as OntimeEvent).cue).toBe('X1.105');
expect((rundown.entries['d'] as OntimeEvent).cue).toBe('X1.155');
});
});
describe('rundownMutation.reorder()', () => {
it('moves an event into a group', () => {
const rundown = makeRundown({
@@ -9,6 +9,7 @@ import {
deleteById,
doesInvalidateMetadata,
duplicateRundown,
getIntegerAndFraction,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
@@ -281,3 +282,29 @@ describe('makeDeepClone()', () => {
]);
});
});
describe('getIntegerAndFraction()', () => {
test('integer without fraction', () => {
expect(getIntegerAndFraction('123')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
});
test('integer and fraction', () => {
expect(getIntegerAndFraction('123.456')).toStrictEqual({ integer: 123, faction: 456, precision: 3 });
});
test('invalid integer', () => {
expect(() => getIntegerAndFraction('abc.456')).toThrowError('input can not be converted to a number');
});
test('indicate precision just with zeros', () => {
expect(getIntegerAndFraction('123.000')).toStrictEqual({ integer: 123, faction: 0, precision: 3 });
});
test('invalid fraction', () => {
expect(() => getIntegerAndFraction('123.abc')).toThrowError('input can not be converted to a number');
});
test('floating separator', () => {
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
});
});
@@ -38,6 +38,7 @@ import {
deleteById,
doesInvalidateMetadata,
getUniqueId,
IncrementNumber,
makeDeepClone,
} from './rundown.utils.js';
@@ -531,6 +532,40 @@ function ungroup(rundown: Rundown, group: OntimeGroup) {
}
}
/**
* Renumbers a range of events
*/
function renumber(
rundown: Rundown,
ids: EntryId[],
prefix: string,
start: IncrementNumber,
increment: IncrementNumber,
) {
const maxPrecision = Math.max(increment.precision, start.precision);
//scale both factions so they have matching precision
increment.faction = increment.faction * Math.pow(10, maxPrecision - increment.precision);
start.faction = start.faction * Math.pow(10, maxPrecision - start.precision);
for (let i = 0; i < ids.length; i++) {
const currentId = ids[i];
const currentEntry = rundown.entries[currentId];
if (!currentEntry || !isOntimeEvent(currentEntry)) throw new Error('A given id was not an event');
//note: we know this dose not handle role over from the fraction into the integer
const integer = String(start.integer + increment.integer * i);
const fraction = maxPrecision
? '.' + String(start.faction + increment.faction * i).padStart(maxPrecision, '0')
: '';
rundownMutation.edit(rundown, {
id: currentId,
cue: `${prefix}${integer}${fraction}`,
});
}
}
export const rundownMutation = {
add: addToRundown,
edit,
@@ -542,6 +577,7 @@ export const rundownMutation = {
clone,
group,
ungroup,
renumber,
};
/**
@@ -1,6 +1,7 @@
import type { Request, Response, Router } from 'express';
import express from 'express';
import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
import { matchedData } from 'express-validator';
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -18,6 +19,7 @@ import {
groupEntries,
initRundown,
loadRundown,
renumberEntries,
reorderEntry,
swapEvents,
ungroupEntries,
@@ -28,6 +30,7 @@ import {
entryBatchPutValidator,
entryPostValidator,
entryPutValidator,
entryRenumberValidator,
entryReorderValidator,
entrySwapValidator,
rundownArrayOfIds,
@@ -373,4 +376,23 @@ router.delete(
},
);
/**
* Reorders two entries in a rundown
*/
router.patch(
'/:rundownId/renumber',
entryRenumberValidator,
validateRundownMutation,
(req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const { ids, prefix, start, increment } = matchedData<RenumberCues>(req);
const rundown = renumberEntries(ids, prefix, start, increment);
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
},
);
// #endregion operations on rundown entries =======================
@@ -33,7 +33,7 @@ import {
updateBackgroundRundown,
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, hasChanges } from './rundown.utils.js';
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
/**
* creates a new entry with given data
@@ -61,7 +61,7 @@ export async function addEntry(eventData: EventPostPayload): Promise<OntimeEntry
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
// generate a fully formed entry from the patch
const newEntry = generateEvent(rundown, eventData, afterId);
const newEntry = generateEvent(rundown, eventData, afterId, parent?.id);
// make mutations to rundown
rundownMutation.add(rundown, newEntry, afterId, parent);
@@ -140,7 +140,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
let batchDidInvalidate = false;
const changedIds: EntryId[] = [];
const patchedEntries: OntimeEntry[] = [];
for (let i = 0; i < ids.length; i++) {
const currentId = ids[i];
const currentEntry = rundown.entries[currentId];
@@ -165,10 +165,9 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
continue;
}
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
const { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
changedIds.push(currentId);
patchedEntries.push(entry);
if (didInvalidate) {
batchDidInvalidate = true;
@@ -270,6 +269,30 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
return rundownResult;
}
/**
* @throws if an id is missing or not an Ontime event
*/
export function renumberEntries(ids: EntryId[], prefix: string, start: string, increment: string): Rundown {
const startNumber = getIntegerAndFraction(start);
const incrementNumber = getIntegerAndFraction(increment);
// if the prefix doesn't already include a separator or is empty, then insert a separator
if (prefix !== '' && !prefix.endsWith('-') && !prefix.endsWith(' ')) prefix += ' ';
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
rundownMutation.renumber(rundown, ids, prefix, startNumber, incrementNumber);
const { rundown: rundownResult, rundownMetadata, revision } = commit(false);
setImmediate(() => {
updateRuntimeOnChange(rundownMetadata);
notifyChanges(rundownMetadata, revision, { timer: ids, external: true });
});
return rundownResult;
}
/**
* Applies a delay into the rundown effectively changing the schedule
* The applied delay is deleted
@@ -50,9 +50,12 @@ type CompleteEntry<T> =
*/
export function generateEvent<
T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
>(rundown: Rundown, eventData: T, afterId: EntryId | null): CompleteEntry<T> {
>(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry<T> {
if (isOntimeEvent(eventData)) {
return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry<T>;
return createEvent(
eventData,
getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent),
) as CompleteEntry<T>;
}
const id = eventData.id || getUniqueId(rundown);
@@ -470,3 +473,30 @@ export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
return newRundown;
}
export type IncrementNumber = {
integer: number;
faction: number;
precision: number;
};
/**
* Parses a decimal string into integer part, fractional digits as an integer, and fractional digit count.
* Splits on the first `.` only
*
* @param value - Numeric string, e.g. `"123"` or `"123.456"`.
* @returns `integer` whole part, `faction` digits after the point as a number (0 when no fraction), `precision` digit count after `.`.
* @throws {Error} When the integer or fractional segment is not parseable as number
*/
export function getIntegerAndFraction(value: string): IncrementNumber {
const [integerStr, factionStr] = value.split('.', 2);
const integer = parseInt(integerStr);
const precision = (factionStr ?? '').length;
const faction = precision === 0 ? 0 : parseInt(factionStr);
if (isNaN(integer) || isNaN(faction)) throw new Error('input can not be converted to a number');
return {
integer,
faction,
precision,
};
}
@@ -83,4 +83,13 @@ export const rundownArrayOfIds = [
requestValidationFunction,
];
export const entryRenumberValidator = [
body('ids').isArray().notEmpty(),
body('ids.*').isString(),
body('prefix').isString(),
body('start').isDecimal(),
body('increment').isDecimal(),
requestValidationFunction,
];
// #endregion operations on rundown entries =======================