From 1a08b39b8b264898d5749029681b1864bccd98c2 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sun, 3 May 2026 18:39:00 +0200 Subject: [PATCH] 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 --- apps/client/src/common/api/rundown.ts | 17 ++- .../client/src/common/hooks/useEntryAction.ts | 36 +++++ .../src/features/rundown/RundownExport.tsx | 2 + .../composite/EventEditorTitles.tsx | 7 +- .../RenumberCuesDialog.module.scss | 22 +++ .../RenumberCuesDialog.tsx | 131 ++++++++++++++++++ .../rundown/rundown-event/RundownEvent.tsx | 11 +- .../rundown/__tests__/rundown.dao.test.ts | 94 ++++++++++++- .../rundown/__tests__/rundown.utils.test.ts | 27 ++++ .../src/api-data/rundown/rundown.dao.ts | 36 +++++ .../src/api-data/rundown/rundown.router.ts | 24 +++- .../src/api-data/rundown/rundown.service.ts | 33 ++++- .../src/api-data/rundown/rundown.utils.ts | 34 ++++- .../api-data/rundown/rundown.validation.ts | 9 ++ .../features/209-rundown-shortcuts.spec.ts | 2 +- .../BackendResponse.type.ts | 7 + packages/types/src/index.ts | 1 + packages/utils/index.ts | 1 - packages/utils/src/cue-utils/cueUtils.test.ts | 94 ++++++------- packages/utils/src/cue-utils/cueUtils.ts | 110 +++++---------- 20 files changed, 554 insertions(+), 144 deletions(-) create mode 100644 apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.module.scss create mode 100644 apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index e2cebb889..fa2381ab9 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -1,5 +1,13 @@ import axios, { AxiosResponse } from 'axios'; -import { EntryId, OntimeEntry, OntimeEvent, ProjectRundownsList, Rundown, TransientEventPayload } from 'ontime-types'; +import { + EntryId, + OntimeEntry, + OntimeEvent, + ProjectRundownsList, + RenumberCues, + Rundown, + TransientEventPayload, +} from 'ontime-types'; import { apiEntryUrl } from './constants'; import type { RequestOptions } from './requestOptions'; @@ -111,6 +119,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn return axios.put(`${rundownPath}/${rundownId}/batch`, data); } +/** + * HTTP request to renumber cues for multiple events + */ +export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise> { + return axios.patch(`${rundownPath}/${rundownId}/renumber`, data); +} + export type ReorderEntry = { entryId: EntryId; destinationId: EntryId; diff --git a/apps/client/src/common/hooks/useEntryAction.ts b/apps/client/src/common/hooks/useEntryAction.ts index ea27b04c7..3eba25086 100644 --- a/apps/client/src/common/hooks/useEntryAction.ts +++ b/apps/client/src/common/hooks/useEntryAction.ts @@ -41,6 +41,7 @@ import { patchReorderEntry, postAddEntry, postCloneEntry, + patchRenumberCues, putBatchEditEvents, putEditEntry, requestApplyDelay, @@ -523,6 +524,39 @@ export const useEntryActions = () => { [batchUpdateEventsMutation, getCurrentRundownData], ); + const { mutateAsync: renumberCuesMutation } = useMutation({ + mutationFn: ([rundownId, body]: Parameters) => patchRenumberCues(rundownId, body), + onMutate: async () => { + const queryKey = resolveCurrentRundownQueryKey(); + await queryClient.cancelQueries({ queryKey }); + const previousRundown = queryClient.getQueryData(queryKey); + return { previousRundown, queryKey }; + }, + onSuccess: (response, _variables, context) => { + if (!response.data || !context?.queryKey) return; + const updatedRundown = response.data; + queryClient.setQueryData(context.queryKey, updatedRundown); + }, + onError: (_error, _vars, context) => { + if (context?.previousRundown) queryClient.setQueryData(context.queryKey, context.previousRundown); + }, + }); + + const renumberCues = useCallback( + async (eventIds: EntryId[], prefix: string, start: string, increment: string) => { + const rundown = getCurrentRundownData(); + const rundownId = rundown?.id; + if (!rundownId) throw new Error('Rundown not initialized'); + try { + await renumberCuesMutation([rundownId, { ids: eventIds, prefix, start, increment }]); + } catch (error) { + logAxiosError('Error renumbering cues', error); + throw error; + } + }, + [getCurrentRundownData, renumberCuesMutation], + ); + /** * Calls mutation to delete an entry * @private @@ -947,6 +981,7 @@ export const useEntryActions = () => { groupEntries, move, reorderEntry, + renumberCues, swapEvents, updateEntry, updateTimer, @@ -963,6 +998,7 @@ export const useEntryActions = () => { groupEntries, move, reorderEntry, + renumberCues, swapEvents, updateEntry, updateTimer, diff --git a/apps/client/src/features/rundown/RundownExport.tsx b/apps/client/src/features/rundown/RundownExport.tsx index 55cc2c1d3..bfe6ae1c6 100644 --- a/apps/client/src/features/rundown/RundownExport.tsx +++ b/apps/client/src/features/rundown/RundownExport.tsx @@ -16,6 +16,7 @@ import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditMo import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout'; import RundownEntryEditor from './entry-editor/RundownEntryEditor'; import FinderPlacement from './placements/FinderPlacement'; +import RenumberCuesDialog from './renumber-cues-dialog/RenumberCuesDialog'; import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu'; import RundownHeader from './rundown-header/RundownHeader'; import RundownHeaderMobile from './rundown-header/RundownHeaderMobile'; @@ -112,6 +113,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund )} {viewMode === RundownViewMode.List ? : } {viewMode === RundownViewMode.Table && } + ); } diff --git a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx index 902fd65f6..2eae440fd 100644 --- a/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx +++ b/apps/client/src/features/rundown/entry-editor/composite/EventEditorTitles.tsx @@ -1,4 +1,3 @@ -import { sanitiseCue } from 'ontime-utils'; import { memo } from 'react'; import * as Editor from '../../../../common/components/editor-utils/EditorUtils'; @@ -24,10 +23,6 @@ export default memo(EventEditorTitles); function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) { const { updateEntry } = useEntryActionsContext(); - const cueSubmitHandler = (_field: string, newValue: string) => { - updateEntry({ id: eventId, cue: sanitiseCue(newValue) }); - }; - const flagSubmitHandler = (newValue: boolean) => { updateEntry({ id: eventId, flag: newValue }); }; @@ -48,7 +43,7 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi field='cue' label='Cue' initialValue={cue} - submitHandler={cueSubmitHandler} + submitHandler={textSubmitHandler} maxLength={10} />
diff --git a/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.module.scss b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.module.scss new file mode 100644 index 000000000..c5db4dc8b --- /dev/null +++ b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.module.scss @@ -0,0 +1,22 @@ +@use '../../../theme/ontimeColours' as *; +@use '../../../theme/ontimeStyles' as *; + +.fields { + display: flex; + flex-direction: column; + gap: 1rem; + padding-inline: 0.5rem; + margin-bottom: 0.5rem; +} + +.label { + font-size: $inner-section-text-size; + color: $label-gray; +} + +.error { + padding-inline: 0.5rem; + font-size: $inner-section-text-size; + color: $error-red; + margin: 0; +} diff --git a/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx new file mode 100644 index 000000000..682f1290c --- /dev/null +++ b/apps/client/src/features/rundown/renumber-cues-dialog/RenumberCuesDialog.tsx @@ -0,0 +1,131 @@ +import { RenumberCues } from 'ontime-types'; +import { useForm } from 'react-hook-form'; +import { create } from 'zustand'; + +import { maybeAxiosError } from '../../../common/api/utils'; +import Button from '../../../common/components/buttons/Button'; +import Dialog from '../../../common/components/dialog/Dialog'; +import Input from '../../../common/components/input/input/Input'; +import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; +import useRundown from '../../../common/hooks-query/useRundown'; +import { orderEntries } from '../rundown.utils'; +import { useEventSelection } from '../useEventSelection'; + +import style from './RenumberCuesDialog.module.scss'; + +type RenumberCueData = Pick; + +export default function RenumberCuesDialog() { + 'use memo'; + const { data } = useRundown(); + const { flatOrder } = data; + const { onClose, isOpen } = useRenumberCuesDialogStore(); + const { renumberCues } = useEntryActionsContext(); + const selectedEvents = useEventSelection((state) => state.selectedEvents); + + const { + register, + handleSubmit, + setError, + clearErrors, + formState: { errors, isSubmitting }, + } = useForm(); + + const onSubmit = async (data: RenumberCueData) => { + clearErrors(); + try { + const { prefix, start, increment } = data; + const orderedEvents = orderEntries(Array.from(selectedEvents), flatOrder); + await renumberCues(orderedEvents, prefix, start, increment); + onClose(); + } catch (error) { + const message = maybeAxiosError(error); + setError('root', { message }); + } + }; + + return ( + +
+ +
+
+ +
+
+ +
+ {errors.root &&

{errors.root.message}

} + + } + footerElements={ + <> + + + + } + /> + ); +} + +interface RenumberCuesDialogState { + isOpen: boolean; + onClose: () => void; + onOpen: () => void; +} + +export const useRenumberCuesDialogStore = create()((set) => ({ + isOpen: false, + onClose: () => { + set({ isOpen: false }); + }, + onOpen: () => { + set({ isOpen: true }); + }, +})); diff --git a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx index 714e97c64..d7496c016 100644 --- a/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx +++ b/apps/client/src/features/rundown/rundown-event/RundownEvent.tsx @@ -13,13 +13,14 @@ import { IoTrash, IoUnlink, } from 'react-icons/io5'; -import { TbFlagFilled } from 'react-icons/tb'; +import { TbFlagFilled, TbListNumbers } from 'react-icons/tb'; import { useEntryActionsContext } from '../../../common/context/EntryActionsContext'; import { useContextMenu } from '../../../common/hooks/useContextMenu'; import { useEntryCopy } from '../../../common/stores/entryCopyStore'; import { deviceMod } from '../../../common/utils/deviceUtils'; import { cx, getAccessibleColour } from '../../../common/utils/styleUtils'; +import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog'; import { useEventIdSwapping } from '../useEventIdSwapping'; import { getSelectionMode, useEventSelection } from '../useEventSelection'; import RundownEventInner from './RundownEventInner'; @@ -99,6 +100,7 @@ export default function RundownEvent({ const selectedEventId = useEventIdSwapping((state) => state.selectedEventId); const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId); const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId); + const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen); const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext(); @@ -143,6 +145,13 @@ export default function RundownEvent({ disabled: parent !== null, }, { type: 'divider' }, + { + type: 'item', + label: 'Renumber cues', + icon: TbListNumbers, + onClick: openRenumberDialog, + }, + { type: 'divider' }, { type: 'item', label: 'Delete', diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts index aae0b3a63..bbadf159c 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.dao.test.ts @@ -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({ diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index 5a16c8930..063bdba12 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -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 }); + }); +}); diff --git a/apps/server/src/api-data/rundown/rundown.dao.ts b/apps/server/src/api-data/rundown/rundown.dao.ts index 456ed0a51..8cb1a81f1 100644 --- a/apps/server/src/api-data/rundown/rundown.dao.ts +++ b/apps/server/src/api-data/rundown/rundown.dao.ts @@ -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, }; /** diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index 46e33c675..8d6dfb603 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -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) => { + try { + const { ids, prefix, start, increment } = matchedData(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 ======================= diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 48a8873e5..5534c0083 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -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 { + 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 diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 84ae435eb..740f56cba 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -50,9 +50,12 @@ type CompleteEntry = */ export function generateEvent< T extends Partial | Partial | Partial | Partial, ->(rundown: Rundown, eventData: T, afterId: EntryId | null): CompleteEntry { +>(rundown: Rundown, eventData: T, afterId: EntryId | null, parent?: EntryId): CompleteEntry { if (isOntimeEvent(eventData)) { - return createEvent(eventData, getCueCandidate(rundown.entries, rundown.order, afterId)) as CompleteEntry; + return createEvent( + eventData, + getCueCandidate(rundown.entries, rundown.flatOrder, afterId, parent), + ) as CompleteEntry; } 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, + }; +} diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 4d0d0d946..5984bc40d 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -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 ======================= diff --git a/e2e/tests/features/209-rundown-shortcuts.spec.ts b/e2e/tests/features/209-rundown-shortcuts.spec.ts index a3717f9d2..97c6d5955 100644 --- a/e2e/tests/features/209-rundown-shortcuts.spec.ts +++ b/e2e/tests/features/209-rundown-shortcuts.spec.ts @@ -191,7 +191,7 @@ test('Add event', async ({ page }) => { // add event above await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+E'); await expect(page.getByTestId('rundown-event')).toHaveCount(3); - await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('0.1'); + await expect(page.getByTestId('entry-1').getByTestId('rundown-event')).toContainText('1'); }); test('Delete event', async ({ page }) => { diff --git a/packages/types/src/api/rundown-controller/BackendResponse.type.ts b/packages/types/src/api/rundown-controller/BackendResponse.type.ts index edc1f90e8..71445f394 100644 --- a/packages/types/src/api/rundown-controller/BackendResponse.type.ts +++ b/packages/types/src/api/rundown-controller/BackendResponse.type.ts @@ -29,3 +29,10 @@ export type RundownSummary = { start: MaybeNumber; end: MaybeNumber; }; + +export type RenumberCues = { + ids: EntryId[]; + prefix: string; + start: string; + increment: string; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index f6188642c..d56bbde2d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -89,6 +89,7 @@ export type { ProjectRundownsList, TransientEventPayload, RundownSummary, + RenumberCues, } from './api/rundown-controller/BackendResponse.type.js'; export type { LinkOptions } from './api/session-controller/BackendResponse.type.js'; export type { CustomViewSummary, CustomViewsListResponse } from './api/custom-views/customViews.type.js'; diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 200f12752..6ba939175 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -4,7 +4,6 @@ export { isKnownTimerType, validateTimeStrategy } from './src/validate-events/va export { calculateDuration, getLinkedTimes, validateTimes } from './src/validate-times/validateTimes.js'; // rundown utils -export { sanitiseCue } from './src/cue-utils/cueUtils.js'; export { getCueCandidate } from './src/cue-utils/cueUtils.js'; export { generateId } from './src/generate-id/generateId.js'; export { diff --git a/packages/utils/src/cue-utils/cueUtils.test.ts b/packages/utils/src/cue-utils/cueUtils.test.ts index 78dbe7653..51eacd055 100644 --- a/packages/utils/src/cue-utils/cueUtils.test.ts +++ b/packages/utils/src/cue-utils/cueUtils.test.ts @@ -1,7 +1,7 @@ -import type { OntimeDelay, OntimeEntry, OntimeEvent, RundownEntries } from 'ontime-types'; +import type { OntimeDelay, OntimeEntry, OntimeEvent, OntimeGroup, OntimeMilestone, RundownEntries } from 'ontime-types'; import { SupportedEntry } from 'ontime-types'; -import { getCueCandidate, getIncrement, sanitiseCue } from './cueUtils.js'; +import { getCueCandidate, getIncrement } from './cueUtils.js'; describe('getIncrement()', () => { it('increments number', () => { @@ -12,24 +12,30 @@ describe('getIncrement()', () => { }); it('increments decimal number', () => { expect(getIncrement('1.1')).toBe('1.2'); + expect(getIncrement('1.9')).toBe('1.10'); expect(getIncrement('10.10')).toBe('10.11'); expect(getIncrement('99.99')).toBe('99.100'); expect(getIncrement('101.101')).toBe('101.102'); - // NOTE: we know the below would fail, handling this amount of decimals is outside of scope - // expect(getIncrement('101.999')).toBe('101.1000'); + expect(getIncrement('101.999')).toBe('101.1000'); }); // NOTE: we also know the following fails since we only handle one decimal - //it('handles multiple decimals', () => { - // expect(getIncrement('2.1.1')).toBe('2.1.2'); - //}); - it('finds last digit in string', () => { + it.fails('handles multiple decimals', () => { + expect(getIncrement('2.1.1')).toBe('2.1.2'); + }); + it('finds last digit in string without separator', () => { expect(getIncrement('Presenter1')).toBe('Presenter2'); expect(getIncrement('Presenter10')).toBe('Presenter11'); expect(getIncrement('Presenter99')).toBe('Presenter100'); expect(getIncrement('Presenter101')).toBe('Presenter102'); }); + it('finds last digit in string with space separator', () => { + expect(getIncrement('Presenter 1')).toBe('Presenter 2'); + expect(getIncrement('Presenter 10')).toBe('Presenter 11'); + expect(getIncrement('Presenter 99')).toBe('Presenter 100'); + expect(getIncrement('Presenter 101')).toBe('Presenter 102'); + }); it('adds a 2 if none is found', () => { - expect(getIncrement('Presenter')).toBe('Presenter2'); + expect(getIncrement('Presenter')).toBe('Presenter-2'); }); }); @@ -43,15 +49,6 @@ describe('getCueCandidate()', () => { const cue = getCueCandidate(entries, ['1', '2'], null); expect(cue).toBe('1'); }); - - it('creates decimal stem if next cue is 1', () => { - const entries: RundownEntries = { - '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, - '2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent, - }; - const cue = getCueCandidate(entries, ['1', '2'], null); - expect(cue).toBe('0.1'); - }); }); describe('in the middle of the rundown', () => { @@ -74,10 +71,10 @@ describe('getCueCandidate()', () => { } as OntimeEntry, }; const cue = getCueCandidate(entries, ['1', '2'], '1'); - expect(cue).toBe('Presenter2'); + expect(cue).toBe('Presenter-2'); }); - it('creates decimal stem if next cue has same stem (case of numbers)', () => { + it.fails('creates decimal stem if next cue has same stem (case of numbers)', () => { const entries: RundownEntries = { '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '2', type: SupportedEntry.Event } as OntimeEvent, @@ -86,7 +83,7 @@ describe('getCueCandidate()', () => { expect(cue).toBe('1.1'); }); - it('creates decimal stem if next cue has same stem (case of letters)', () => { + it.fails('creates decimal stem if next cue has same stem (case of letters)', () => { const entries: RundownEntries = { '1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: 'Presenter2', type: SupportedEntry.Event } as OntimeEvent, @@ -99,22 +96,33 @@ describe('getCueCandidate()', () => { describe('considers edge cases', () => { it('previousEvent might not be a cue', () => { const entries: RundownEntries = { - '1': { id: '1', cue: '10', type: SupportedEntry.Event } as OntimeEvent, + '0': { id: '0', cue: '10', type: SupportedEntry.Event } as OntimeEvent, + '1': { id: '1', type: SupportedEntry.Milestone } as OntimeMilestone, + '2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay, + }; + const cue = getCueCandidate(entries, ['0', '1', '2'], '2'); + expect(cue).toBe('11'); + }); + + it('previousEvent might not be a group', () => { + const entries: RundownEntries = { + '0': { id: '0', cue: '10', type: SupportedEntry.Event } as OntimeEvent, + '1': { id: '1', type: SupportedEntry.Milestone } as OntimeMilestone, + '2': { id: '2', type: SupportedEntry.Group } as OntimeGroup, + }; + const cue = getCueCandidate(entries, ['0', '1', '2'], null, '2'); + expect(cue).toBe('11'); + }); + + it('there might not be events before', () => { + const entries: RundownEntries = { + '1': { id: '1', type: SupportedEntry.Delay } as OntimeDelay, '2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay, }; const cue = getCueCandidate(entries, ['1', '2'], '2'); - expect(cue).toBe('11'); + expect(cue).toBe('1'); }); }); - - it('there might not be events before', () => { - const entries: RundownEntries = { - '1': { id: '1', type: SupportedEntry.Delay } as OntimeDelay, - '2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay, - }; - const cue = getCueCandidate(entries, ['1', '2'], '2'); - expect(cue).toBe('1'); - }); }); describe('findCueName() with mixed events', () => { @@ -128,7 +136,8 @@ describe('findCueName() with mixed events', () => { expect(cue).toBe('1'); }); - it('creates decimal stem if next cue is 1', () => { + // we let this fail to reduced complexity + it.fails('creates decimal stem if next cue is 1', () => { const entries: RundownEntries = { '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '10', type: SupportedEntry.Event } as OntimeEvent, @@ -154,10 +163,10 @@ describe('findCueName() with mixed events', () => { '2': { id: '2', cue: 'Interval', type: SupportedEntry.Event } as OntimeEvent, }; const cue = getCueCandidate(entries, ['1', '2'], '1'); - expect(cue).toBe('Presenter2'); + expect(cue).toBe('Presenter-2'); }); - it('creates decimal stem if next cue has same stem (case of numbers)', () => { + it.fails('creates decimal stem if next cue has same stem (case of numbers)', () => { const entries: RundownEntries = { '1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: '2', type: SupportedEntry.Event } as OntimeEvent, @@ -166,7 +175,7 @@ describe('findCueName() with mixed events', () => { expect(cue).toBe('1.1'); }); - it('creates decimal stem if next cue has same stem (case of letters)', () => { + it.fails('creates decimal stem if next cue has same stem (case of letters)', () => { const entries: RundownEntries = { '1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent, '2': { id: '2', cue: 'Presenter2', type: SupportedEntry.Event } as OntimeEvent, @@ -176,16 +185,3 @@ describe('findCueName() with mixed events', () => { }); }); }); - -describe('sanitiseCue()', () => { - it('removes spaces', () => { - expect(sanitiseCue(' test')).toBe('test'); - expect(sanitiseCue(' test ')).toBe('test'); - expect(sanitiseCue('test')).toBe('test'); - expect(sanitiseCue('t e s t ')).toBe('test'); - }); - it('enforces . as decimals', () => { - expect(sanitiseCue('1,2')).toBe('1.2'); - expect(sanitiseCue('1,2,3')).toBe('1.2.3'); - }); -}); diff --git a/packages/utils/src/cue-utils/cueUtils.ts b/packages/utils/src/cue-utils/cueUtils.ts index f078a1a36..3fcfcb2dc 100644 --- a/packages/utils/src/cue-utils/cueUtils.ts +++ b/packages/utils/src/cue-utils/cueUtils.ts @@ -1,13 +1,10 @@ import type { EntryId, OntimeEntry, RundownEntries } from 'ontime-types'; import { isOntimeEvent } from 'ontime-types'; -import { getFirstEventNormal, getNextEventNormal, getPreviousEventNormal } from '../rundown-utils/rundownUtils.js'; -import { isNumeric } from '../types/types.js'; +import { getPreviousEventNormal } from '../rundown-utils/rundownUtils.js'; -// Zero or more non-digit characters at the beginning ((\D*)). -// One or more digits ((\d+)). -// Optionally, a decimal part starting with a dot ((\.\d+)?). -const regex = /^(\D*)(\d+)(\.\d+)?$/; +// Groups: 1=prefix, 2=separator(optional dash or space), 3=integer, 4='.', 5=fraction +const regex = /^(\D*?)(?:([ -]))?(\d+)(?:(\.)(\d+))?$/; /** * Finds if last characters in input are a number and increments @@ -15,84 +12,45 @@ const regex = /^(\D*)(\d+)(\.\d+)?$/; export function getIncrement(input: string): string { // Check if the input string contains a number at the end const match = regex.exec(input); - if (match) { - // If a number is found, extract the non-numeric prefix, integer part, and decimal part - // eslint-disable-next-line prefer-const -- some items in the destructuring are modified - let [, prefix, integerPart, decimalPart] = match; - - if (decimalPart) { - if (decimalPart === '.99') { - decimalPart = '.100'; - } else { - const addDecimal = `${'0'.repeat(decimalPart.length - 2)}1`; - const incrementedDecimal = (Number(decimalPart) + Number(`0.${addDecimal}`)).toFixed(decimalPart.length - 1); - decimalPart = incrementedDecimal.toString().replace('0.', '.'); - } - return `${prefix}${integerPart}${decimalPart}`; - } - const incrementedInteger = Number(integerPart) + 1; - integerPart = incrementedInteger.toString(); - return `${prefix}${integerPart}`; - } - // If no number is found, append "2" to the string and return the updated string - return `${input}2`; + if (match === null) return `${input}-2`; + const [, prefix, separator, integerPart, _decimalSeparator, decimalPart] = match; + if (decimalPart === undefined) return incrementInteger(prefix, integerPart, separator); + return incrementDecimal(prefix, integerPart, decimalPart, separator); } +const incrementDecimal = (prefix: string, integerPart: string, decimalPart: string, separator = '') => { + const decimalInteger = parseInt(decimalPart); + const incrementedDecimal = (decimalInteger + 1).toString(); + const newDecimalPart = incrementedDecimal.padStart(decimalPart.length, '0'); + return `${prefix}${separator ?? ''}${integerPart}.${newDecimalPart}`; +}; + +const incrementInteger = (prefix: string, integerPart: string, separator = '') => { + const incrementedInteger = parseInt(integerPart) + 1; + const newIntegerPart = incrementedInteger.toString(); + return `${prefix}${separator}${newIntegerPart}`; +}; + /** * Gets suitable name for a new event cue */ -export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId: EntryId | null): string { - // we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1 - if (insertAfterId === null || order.length === 0) { - return addAtTop(); - } +export function getCueCandidate( + entries: RundownEntries, + flatOrder: EntryId[], + insertAfterId: EntryId | null, + parent?: EntryId, +): string { + // we might not get a insertAfterId if we are inserting at the top of a group + // in that case we need to get the id of the group so we can find the proceeding event + const prevId = insertAfterId ? insertAfterId : (parent ?? null); + if (flatOrder.length === 0 || prevId === null) return '1'; - // get the given event, or any before that - let previousEvent: OntimeEntry | null | undefined = entries[insertAfterId]; + let previousEvent: OntimeEntry | null | undefined = entries[prevId]; if (!isOntimeEvent(previousEvent)) { - previousEvent = getPreviousEventNormal(entries, order, insertAfterId).previousEvent; - if (!isOntimeEvent(previousEvent)) { - return addAtTop(); - } + previousEvent = getPreviousEventNormal(entries, flatOrder, prevId).previousEvent; + if (!isOntimeEvent(previousEvent)) return '1'; } - // the cue is based on the previous event cue - const cue = getIncrement(previousEvent.cue); - const { nextEvent } = getNextEventNormal(entries, order, insertAfterId); - - // if increment is clashing with next, we add a decimal instead - if (cue !== nextEvent?.cue) { - return cue; - } - - // there is a clash, bt the cue is a pure number - if (isNumeric(cue)) { - return incrementDecimal(previousEvent.cue); - } - - /** - * at this point, we know the cue is not numeric - * but the increment failed, so we have a numeric ending - * eg. Presenter 1 .... Presenter 2 -> Presenter1.1 - * eg. Presenter 1.1 .... Presenter 1.2 -> Presenter1.1.1 - */ - return `${previousEvent.cue}.1`; - - function incrementDecimal(cue: string) { - const n = Number(cue); - return (n + 0.1).toString(); - } - - function addAtTop() { - const firstEventCue = getFirstEventNormal(entries, order).firstEvent?.cue; - if (firstEventCue === '1') { - return '0.1'; - } - return '1'; - } -} - -export function sanitiseCue(cue: string) { - return cue.replaceAll(' ', '').replaceAll(',', '.'); + return getIncrement(previousEvent.cue); }