mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-27 09:59:08 +00:00
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:
committed by
GitHub
parent
ba1c3235b3
commit
1a08b39b8b
@@ -1,5 +1,13 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
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 { apiEntryUrl } from './constants';
|
||||||
import type { RequestOptions } from './requestOptions';
|
import type { RequestOptions } from './requestOptions';
|
||||||
@@ -111,6 +119,13 @@ export async function putBatchEditEvents(rundownId: RundownId, data: BatchEditEn
|
|||||||
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
return axios.put(`${rundownPath}/${rundownId}/batch`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request to renumber cues for multiple events
|
||||||
|
*/
|
||||||
|
export function patchRenumberCues(rundownId: RundownId, data: RenumberCues): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.patch(`${rundownPath}/${rundownId}/renumber`, data);
|
||||||
|
}
|
||||||
|
|
||||||
export type ReorderEntry = {
|
export type ReorderEntry = {
|
||||||
entryId: EntryId;
|
entryId: EntryId;
|
||||||
destinationId: EntryId;
|
destinationId: EntryId;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
patchReorderEntry,
|
patchReorderEntry,
|
||||||
postAddEntry,
|
postAddEntry,
|
||||||
postCloneEntry,
|
postCloneEntry,
|
||||||
|
patchRenumberCues,
|
||||||
putBatchEditEvents,
|
putBatchEditEvents,
|
||||||
putEditEntry,
|
putEditEntry,
|
||||||
requestApplyDelay,
|
requestApplyDelay,
|
||||||
@@ -523,6 +524,39 @@ export const useEntryActions = () => {
|
|||||||
[batchUpdateEventsMutation, getCurrentRundownData],
|
[batchUpdateEventsMutation, getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: renumberCuesMutation } = useMutation({
|
||||||
|
mutationFn: ([rundownId, body]: Parameters<typeof patchRenumberCues>) => patchRenumberCues(rundownId, body),
|
||||||
|
onMutate: async () => {
|
||||||
|
const queryKey = resolveCurrentRundownQueryKey();
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
const previousRundown = queryClient.getQueryData<Rundown>(queryKey);
|
||||||
|
return { previousRundown, queryKey };
|
||||||
|
},
|
||||||
|
onSuccess: (response, _variables, context) => {
|
||||||
|
if (!response.data || !context?.queryKey) return;
|
||||||
|
const updatedRundown = response.data;
|
||||||
|
queryClient.setQueryData<Rundown>(context.queryKey, updatedRundown);
|
||||||
|
},
|
||||||
|
onError: (_error, _vars, context) => {
|
||||||
|
if (context?.previousRundown) queryClient.setQueryData<Rundown>(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
|
* Calls mutation to delete an entry
|
||||||
* @private
|
* @private
|
||||||
@@ -947,6 +981,7 @@ export const useEntryActions = () => {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
move,
|
move,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
|
renumberCues,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
@@ -963,6 +998,7 @@ export const useEntryActions = () => {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
move,
|
move,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
|
renumberCues,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import EntryEditModal from '../../views/cuesheet/cuesheet-edit-modal/EntryEditMo
|
|||||||
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
import { EditorLayoutMode, useEditorLayout } from '../../views/editor/useEditorLayout';
|
||||||
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
import RundownEntryEditor from './entry-editor/RundownEntryEditor';
|
||||||
import FinderPlacement from './placements/FinderPlacement';
|
import FinderPlacement from './placements/FinderPlacement';
|
||||||
|
import RenumberCuesDialog from './renumber-cues-dialog/RenumberCuesDialog';
|
||||||
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
import { RundownContextMenu } from './rundown-context-menu/RundownContextMenu';
|
||||||
import RundownHeader from './rundown-header/RundownHeader';
|
import RundownHeader from './rundown-header/RundownHeader';
|
||||||
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
import RundownHeaderMobile from './rundown-header/RundownHeaderMobile';
|
||||||
@@ -112,6 +113,7 @@ function RundownRoot({ isSmallDevice, isExtracted, viewMode, setViewMode }: Rund
|
|||||||
)}
|
)}
|
||||||
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
{viewMode === RundownViewMode.List ? <RundownList /> : <RundownTable />}
|
||||||
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
{viewMode === RundownViewMode.Table && <EntryEditModal />}
|
||||||
|
<RenumberCuesDialog />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { sanitiseCue } from 'ontime-utils';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import * as Editor from '../../../../common/components/editor-utils/EditorUtils';
|
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) {
|
function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEditorTitlesProps) {
|
||||||
const { updateEntry } = useEntryActionsContext();
|
const { updateEntry } = useEntryActionsContext();
|
||||||
|
|
||||||
const cueSubmitHandler = (_field: string, newValue: string) => {
|
|
||||||
updateEntry({ id: eventId, cue: sanitiseCue(newValue) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const flagSubmitHandler = (newValue: boolean) => {
|
const flagSubmitHandler = (newValue: boolean) => {
|
||||||
updateEntry({ id: eventId, flag: newValue });
|
updateEntry({ id: eventId, flag: newValue });
|
||||||
};
|
};
|
||||||
@@ -48,7 +43,7 @@ function EventEditorTitles({ eventId, cue, flag, title, note, colour }: EventEdi
|
|||||||
field='cue'
|
field='cue'
|
||||||
label='Cue'
|
label='Cue'
|
||||||
initialValue={cue}
|
initialValue={cue}
|
||||||
submitHandler={cueSubmitHandler}
|
submitHandler={textSubmitHandler}
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<RenumberCues, 'increment' | 'prefix' | 'start'>;
|
||||||
|
|
||||||
|
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<RenumberCueData>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title='Renumber cues'
|
||||||
|
showCloseButton
|
||||||
|
showBackdrop
|
||||||
|
bodyElements={
|
||||||
|
<form id='renumber-cues-form' onSubmit={handleSubmit(onSubmit)} className={style.fields}>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Prefix
|
||||||
|
<Input
|
||||||
|
{...register('prefix')}
|
||||||
|
type='text'
|
||||||
|
maxLength={8}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='A'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Start
|
||||||
|
<Input
|
||||||
|
{...register('start')}
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
step={0.001}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='10'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={style.field}>
|
||||||
|
<label className={style.label}>
|
||||||
|
Increment
|
||||||
|
<Input
|
||||||
|
{...register('increment')}
|
||||||
|
type='number'
|
||||||
|
required
|
||||||
|
step={0.001}
|
||||||
|
height='large'
|
||||||
|
fluid
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder='0.1'
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.root && <p className={style.error}>{errors.root.message}</p>}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
footerElements={
|
||||||
|
<>
|
||||||
|
<Button type='button' variant='subtle-white' onClick={onClose} disabled={isSubmitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' variant='primary' form='renumber-cues-form' loading={isSubmitting}>
|
||||||
|
Renumber
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RenumberCuesDialogState {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRenumberCuesDialogStore = create<RenumberCuesDialogState>()((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
onClose: () => {
|
||||||
|
set({ isOpen: false });
|
||||||
|
},
|
||||||
|
onOpen: () => {
|
||||||
|
set({ isOpen: true });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
IoTrash,
|
IoTrash,
|
||||||
IoUnlink,
|
IoUnlink,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { TbFlagFilled } from 'react-icons/tb';
|
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
|
import { useRenumberCuesDialogStore } from '../renumber-cues-dialog/RenumberCuesDialog';
|
||||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||||
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
import { getSelectionMode, useEventSelection } from '../useEventSelection';
|
||||||
import RundownEventInner from './RundownEventInner';
|
import RundownEventInner from './RundownEventInner';
|
||||||
@@ -99,6 +100,7 @@ export default function RundownEvent({
|
|||||||
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
|
const selectedEventId = useEventIdSwapping((state) => state.selectedEventId);
|
||||||
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
|
const setSelectedEventId = useEventIdSwapping((state) => state.setSelectedEventId);
|
||||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||||
|
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||||
|
|
||||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||||
|
|
||||||
@@ -143,6 +145,13 @@ export default function RundownEvent({
|
|||||||
disabled: parent !== null,
|
disabled: parent !== null,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Renumber cues',
|
||||||
|
icon: TbListNumbers,
|
||||||
|
onClick: openRenumberDialog,
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('processRundown()', () => {
|
|||||||
Object.keys(result?.entries ?? {}).length,
|
Object.keys(result?.entries ?? {}).length,
|
||||||
'events',
|
'events',
|
||||||
);
|
);
|
||||||
expect(t2 - t1).lessThan(100);
|
expect(t2 - t1).lessThan(120);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates metadata from given rundown', () => {
|
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()', () => {
|
describe('rundownMutation.reorder()', () => {
|
||||||
it('moves an event into a group', () => {
|
it('moves an event into a group', () => {
|
||||||
const rundown = makeRundown({
|
const rundown = makeRundown({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
deleteById,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
duplicateRundown,
|
duplicateRundown,
|
||||||
|
getIntegerAndFraction,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
} from '../rundown.utils.js';
|
} 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,
|
deleteById,
|
||||||
doesInvalidateMetadata,
|
doesInvalidateMetadata,
|
||||||
getUniqueId,
|
getUniqueId,
|
||||||
|
IncrementNumber,
|
||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
} from './rundown.utils.js';
|
} 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 = {
|
export const rundownMutation = {
|
||||||
add: addToRundown,
|
add: addToRundown,
|
||||||
edit,
|
edit,
|
||||||
@@ -542,6 +577,7 @@ export const rundownMutation = {
|
|||||||
clone,
|
clone,
|
||||||
group,
|
group,
|
||||||
ungroup,
|
ungroup,
|
||||||
|
renumber,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Request, Response, Router } from 'express';
|
import type { Request, Response, Router } from 'express';
|
||||||
import express 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 { getErrorMessage } from 'ontime-utils';
|
||||||
|
|
||||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
groupEntries,
|
groupEntries,
|
||||||
initRundown,
|
initRundown,
|
||||||
loadRundown,
|
loadRundown,
|
||||||
|
renumberEntries,
|
||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
entryBatchPutValidator,
|
entryBatchPutValidator,
|
||||||
entryPostValidator,
|
entryPostValidator,
|
||||||
entryPutValidator,
|
entryPutValidator,
|
||||||
|
entryRenumberValidator,
|
||||||
entryReorderValidator,
|
entryReorderValidator,
|
||||||
entrySwapValidator,
|
entrySwapValidator,
|
||||||
rundownArrayOfIds,
|
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 =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
updateBackgroundRundown,
|
updateBackgroundRundown,
|
||||||
} from './rundown.dao.js';
|
} from './rundown.dao.js';
|
||||||
import type { RundownMetadata } from './rundown.types.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
|
* 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);
|
const afterId = getInsertAfterId(rundown, parent, eventData?.after, eventData?.before);
|
||||||
|
|
||||||
// generate a fully formed entry from the patch
|
// 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
|
// make mutations to rundown
|
||||||
rundownMutation.add(rundown, newEntry, afterId, parent);
|
rundownMutation.add(rundown, newEntry, afterId, parent);
|
||||||
@@ -140,7 +140,7 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
|||||||
|
|
||||||
let batchDidInvalidate = false;
|
let batchDidInvalidate = false;
|
||||||
const changedIds: EntryId[] = [];
|
const changedIds: EntryId[] = [];
|
||||||
const patchedEntries: OntimeEntry[] = [];
|
|
||||||
for (let i = 0; i < ids.length; i++) {
|
for (let i = 0; i < ids.length; i++) {
|
||||||
const currentId = ids[i];
|
const currentId = ids[i];
|
||||||
const currentEntry = rundown.entries[currentId];
|
const currentEntry = rundown.entries[currentId];
|
||||||
@@ -165,10 +165,9 @@ export async function batchEditEntries(ids: EntryId[], patch: Partial<OntimeEntr
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { entry, didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
|
const { didInvalidate } = rundownMutation.edit(rundown, { ...patch, id: currentId });
|
||||||
|
|
||||||
changedIds.push(currentId);
|
changedIds.push(currentId);
|
||||||
patchedEntries.push(entry);
|
|
||||||
|
|
||||||
if (didInvalidate) {
|
if (didInvalidate) {
|
||||||
batchDidInvalidate = true;
|
batchDidInvalidate = true;
|
||||||
@@ -270,6 +269,30 @@ export async function reorderEntry(entryId: EntryId, destinationId: EntryId, ord
|
|||||||
return rundownResult;
|
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
|
* Applies a delay into the rundown effectively changing the schedule
|
||||||
* The applied delay is deleted
|
* The applied delay is deleted
|
||||||
|
|||||||
@@ -50,9 +50,12 @@ type CompleteEntry<T> =
|
|||||||
*/
|
*/
|
||||||
export function generateEvent<
|
export function generateEvent<
|
||||||
T extends Partial<OntimeEvent> | Partial<OntimeDelay> | Partial<OntimeGroup> | Partial<OntimeMilestone>,
|
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)) {
|
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);
|
const id = eventData.id || getUniqueId(rundown);
|
||||||
@@ -470,3 +473,30 @@ export function duplicateRundown(rundown: Rundown, newTitle: string): Rundown {
|
|||||||
|
|
||||||
return newRundown;
|
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,
|
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 =======================
|
// #endregion operations on rundown entries =======================
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ test('Add event', async ({ page }) => {
|
|||||||
// add event above
|
// add event above
|
||||||
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '1' }).press('Alt+Shift+E');
|
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('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 }) => {
|
test('Delete event', async ({ page }) => {
|
||||||
|
|||||||
@@ -29,3 +29,10 @@ export type RundownSummary = {
|
|||||||
start: MaybeNumber;
|
start: MaybeNumber;
|
||||||
end: MaybeNumber;
|
end: MaybeNumber;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RenumberCues = {
|
||||||
|
ids: EntryId[];
|
||||||
|
prefix: string;
|
||||||
|
start: string;
|
||||||
|
increment: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export type {
|
|||||||
ProjectRundownsList,
|
ProjectRundownsList,
|
||||||
TransientEventPayload,
|
TransientEventPayload,
|
||||||
RundownSummary,
|
RundownSummary,
|
||||||
|
RenumberCues,
|
||||||
} from './api/rundown-controller/BackendResponse.type.js';
|
} from './api/rundown-controller/BackendResponse.type.js';
|
||||||
export type { LinkOptions } from './api/session-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';
|
export type { CustomViewSummary, CustomViewsListResponse } from './api/custom-views/customViews.type.js';
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export { isKnownTimerType, validateTimeStrategy } from './src/validate-events/va
|
|||||||
export { calculateDuration, getLinkedTimes, validateTimes } from './src/validate-times/validateTimes.js';
|
export { calculateDuration, getLinkedTimes, validateTimes } from './src/validate-times/validateTimes.js';
|
||||||
|
|
||||||
// rundown utils
|
// rundown utils
|
||||||
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
|
|
||||||
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
|
||||||
export { generateId } from './src/generate-id/generateId.js';
|
export { generateId } from './src/generate-id/generateId.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -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 { SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { getCueCandidate, getIncrement, sanitiseCue } from './cueUtils.js';
|
import { getCueCandidate, getIncrement } from './cueUtils.js';
|
||||||
|
|
||||||
describe('getIncrement()', () => {
|
describe('getIncrement()', () => {
|
||||||
it('increments number', () => {
|
it('increments number', () => {
|
||||||
@@ -12,24 +12,30 @@ describe('getIncrement()', () => {
|
|||||||
});
|
});
|
||||||
it('increments decimal number', () => {
|
it('increments decimal number', () => {
|
||||||
expect(getIncrement('1.1')).toBe('1.2');
|
expect(getIncrement('1.1')).toBe('1.2');
|
||||||
|
expect(getIncrement('1.9')).toBe('1.10');
|
||||||
expect(getIncrement('10.10')).toBe('10.11');
|
expect(getIncrement('10.10')).toBe('10.11');
|
||||||
expect(getIncrement('99.99')).toBe('99.100');
|
expect(getIncrement('99.99')).toBe('99.100');
|
||||||
expect(getIncrement('101.101')).toBe('101.102');
|
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
|
// NOTE: we also know the following fails since we only handle one decimal
|
||||||
//it('handles multiple decimals', () => {
|
it.fails('handles multiple decimals', () => {
|
||||||
// expect(getIncrement('2.1.1')).toBe('2.1.2');
|
expect(getIncrement('2.1.1')).toBe('2.1.2');
|
||||||
//});
|
});
|
||||||
it('finds last digit in string', () => {
|
it('finds last digit in string without separator', () => {
|
||||||
expect(getIncrement('Presenter1')).toBe('Presenter2');
|
expect(getIncrement('Presenter1')).toBe('Presenter2');
|
||||||
expect(getIncrement('Presenter10')).toBe('Presenter11');
|
expect(getIncrement('Presenter10')).toBe('Presenter11');
|
||||||
expect(getIncrement('Presenter99')).toBe('Presenter100');
|
expect(getIncrement('Presenter99')).toBe('Presenter100');
|
||||||
expect(getIncrement('Presenter101')).toBe('Presenter102');
|
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', () => {
|
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);
|
const cue = getCueCandidate(entries, ['1', '2'], null);
|
||||||
expect(cue).toBe('1');
|
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', () => {
|
describe('in the middle of the rundown', () => {
|
||||||
@@ -74,10 +71,10 @@ describe('getCueCandidate()', () => {
|
|||||||
} as OntimeEntry,
|
} as OntimeEntry,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2'], '1');
|
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 = {
|
const entries: RundownEntries = {
|
||||||
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '2', 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');
|
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 = {
|
const entries: RundownEntries = {
|
||||||
'1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: 'Presenter2', 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', () => {
|
describe('considers edge cases', () => {
|
||||||
it('previousEvent might not be a cue', () => {
|
it('previousEvent might not be a cue', () => {
|
||||||
const entries: RundownEntries = {
|
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,
|
'2': { id: '2', type: SupportedEntry.Delay } as OntimeDelay,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2'], '2');
|
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', () => {
|
describe('findCueName() with mixed events', () => {
|
||||||
@@ -128,7 +136,8 @@ describe('findCueName() with mixed events', () => {
|
|||||||
expect(cue).toBe('1');
|
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 = {
|
const entries: RundownEntries = {
|
||||||
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '10', 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,
|
'2': { id: '2', cue: 'Interval', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
};
|
};
|
||||||
const cue = getCueCandidate(entries, ['1', '2'], '1');
|
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 = {
|
const entries: RundownEntries = {
|
||||||
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: '1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: '2', 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');
|
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 = {
|
const entries: RundownEntries = {
|
||||||
'1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent,
|
'1': { id: '1', cue: 'Presenter1', type: SupportedEntry.Event } as OntimeEvent,
|
||||||
'2': { id: '2', cue: 'Presenter2', 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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import type { EntryId, OntimeEntry, RundownEntries } from 'ontime-types';
|
import type { EntryId, OntimeEntry, RundownEntries } from 'ontime-types';
|
||||||
import { isOntimeEvent } from 'ontime-types';
|
import { isOntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
import { getFirstEventNormal, getNextEventNormal, getPreviousEventNormal } from '../rundown-utils/rundownUtils.js';
|
import { getPreviousEventNormal } from '../rundown-utils/rundownUtils.js';
|
||||||
import { isNumeric } from '../types/types.js';
|
|
||||||
|
|
||||||
// Zero or more non-digit characters at the beginning ((\D*)).
|
// Groups: 1=prefix, 2=separator(optional dash or space), 3=integer, 4='.', 5=fraction
|
||||||
// One or more digits ((\d+)).
|
const regex = /^(\D*?)(?:([ -]))?(\d+)(?:(\.)(\d+))?$/;
|
||||||
// Optionally, a decimal part starting with a dot ((\.\d+)?).
|
|
||||||
const regex = /^(\D*)(\d+)(\.\d+)?$/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds if last characters in input are a number and increments
|
* 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 {
|
export function getIncrement(input: string): string {
|
||||||
// Check if the input string contains a number at the end
|
// Check if the input string contains a number at the end
|
||||||
const match = regex.exec(input);
|
const match = regex.exec(input);
|
||||||
if (match) {
|
if (match === null) return `${input}-2`;
|
||||||
// If a number is found, extract the non-numeric prefix, integer part, and decimal part
|
const [, prefix, separator, integerPart, _decimalSeparator, decimalPart] = match;
|
||||||
// eslint-disable-next-line prefer-const -- some items in the destructuring are modified
|
if (decimalPart === undefined) return incrementInteger(prefix, integerPart, separator);
|
||||||
let [, prefix, integerPart, decimalPart] = match;
|
return incrementDecimal(prefix, integerPart, decimalPart, separator);
|
||||||
|
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Gets suitable name for a new event cue
|
||||||
*/
|
*/
|
||||||
export function getCueCandidate(entries: RundownEntries, order: EntryId[], insertAfterId: EntryId | null): string {
|
export function getCueCandidate(
|
||||||
// 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
|
entries: RundownEntries,
|
||||||
if (insertAfterId === null || order.length === 0) {
|
flatOrder: EntryId[],
|
||||||
return addAtTop();
|
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[prevId];
|
||||||
let previousEvent: OntimeEntry | null | undefined = entries[insertAfterId];
|
|
||||||
|
|
||||||
if (!isOntimeEvent(previousEvent)) {
|
if (!isOntimeEvent(previousEvent)) {
|
||||||
previousEvent = getPreviousEventNormal(entries, order, insertAfterId).previousEvent;
|
previousEvent = getPreviousEventNormal(entries, flatOrder, prevId).previousEvent;
|
||||||
if (!isOntimeEvent(previousEvent)) {
|
if (!isOntimeEvent(previousEvent)) return '1';
|
||||||
return addAtTop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// the cue is based on the previous event cue
|
return getIncrement(previousEvent.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(',', '.');
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user