Compare commits

..

7 Commits

Author SHA1 Message Date
arc-alex bf3d426649 cleanup: millisToSeconds 2025-05-14 21:58:08 +02:00
arc-alex 55c6e70f4a rename 2025-05-14 21:58:08 +02:00
arc-alex f9f1679e8c tweek update rate 2025-05-14 21:58:08 +02:00
arc-alex fca54ab793 clean unused functions 2025-05-14 21:58:07 +02:00
arc-alex 5a38326fb4 clock "is" a count up timer 2025-05-14 21:58:07 +02:00
arc-alex 108db7c87a apply to Timer view 2025-05-14 21:58:07 +02:00
arc-alex 68080d0bc6 change millisToSeconds function 2025-05-14 21:58:07 +02:00
57 changed files with 481 additions and 1970 deletions
+1 -1
View File
@@ -19,7 +19,7 @@
"@tanstack/react-query-devtools": "^5.62.7",
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.9.0",
"axios": "^1.2.0",
"color": "^4.2.3",
"csv-stringify": "^6.4.5",
"framer-motion": "^10.10.0",
+1 -1
View File
@@ -11,7 +11,7 @@ export type HasUpdate = {
* HTTP request to get the latest version and url from github
*/
export async function getLatestVersion(): Promise<HasUpdate> {
const res = await axios.get(apiRepoLatest);
const res = await axios.get(`${apiRepoLatest}`);
return {
url: res.data.html_url as string,
version: res.data.tag_name as string,
+1 -1
View File
@@ -11,7 +11,7 @@ export const reportUrl = `${apiEntryUrl}/report`;
* HTTP request to fetch all reports
*/
export async function fetchReport(): Promise<OntimeReport> {
const res = await axios.get(reportUrl);
const res = await axios.get(`${reportUrl}/`);
return res.data;
}
+6 -27
View File
@@ -17,7 +17,7 @@ const rundownPath = `${apiEntryUrl}/rundown`;
* HTTP request to fetch a list of existing rundowns
*/
export async function fetchProjectRundownList(): Promise<ProjectRundownsList> {
const res = await axios.get(rundownPath);
const res = await axios.get(`${rundownPath}/`);
return res.data;
}
@@ -56,15 +56,15 @@ export async function putBatchEditEvents(data: BatchEditEntry): Promise<AxiosRes
}
export type ReorderEntry = {
entryId: EntryId;
destinationId: EntryId;
order: 'before' | 'after' | 'insert';
eventId: string;
from: number;
to: number;
};
/**
* HTTP request to reorder an entry
*/
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<Rundown>> {
export async function patchReorderEntry(data: ReorderEntry): Promise<AxiosResponse<OntimeEntry>> {
return axios.patch(`${rundownPath}/reorder`, data);
}
@@ -83,31 +83,10 @@ export async function requestEventSwap(data: SwapEntry): Promise<AxiosResponse<M
/**
* HTTP request to request application of delay
*/
export async function requestApplyDelay(delayId: EntryId): Promise<AxiosResponse<MessageResponse>> {
export async function requestApplyDelay(delayId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.patch(`${rundownPath}/applydelay/${delayId}`);
}
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/clone/${entryId}`);
}
/**
* HTTP request for dissolving of a block
*/
export async function requestUngroup(blockId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/ungroup/${blockId}`);
}
/**
* HTTP request for grouping a list of entries into a block
*/
export async function requestGroupEntries(entryIds: EntryId[]): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/group`, { ids: entryIds });
}
/**
* HTTP request to delete entries
*/
+50 -138
View File
@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
isOntimeBlock,
isOntimeEvent,
MaybeString,
OntimeBlock,
@@ -13,23 +12,19 @@ import {
TimeStrategy,
TransientEventPayload,
} from 'ontime-types';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, swapEventData } from 'ontime-utils';
import { dayInMs, generateId, MILLIS_PER_SECOND, parseUserTime, reorderArray, swapEventData } from 'ontime-utils';
import { moveDown, moveUp } from '../../features/rundown/rundown.utils';
import { RUNDOWN } from '../api/constants';
import {
deleteEntries,
patchReorderEntry,
postAddEntry,
postCloneEntry,
putBatchEditEvents,
putEditEntry,
ReorderEntry,
requestApplyDelay,
requestDeleteAll,
requestEventSwap,
requestGroupEntries,
requestUngroup,
SwapEntry,
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
@@ -79,7 +74,10 @@ export const useEntryActions = () => {
const _addEntryMutation = useMutation({
// TODO(v4): optimistic create entry
mutationFn: postAddEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -166,29 +164,6 @@ export const useEntryActions = () => {
],
);
/**
* Calls mutation to clone a selection
* @private
*/
const _cloneMutation = useMutation({
mutationFn: postCloneEntry,
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Clone a selection
*/
const clone = useCallback(
async (entryId: EntryId) => {
try {
await _cloneMutation.mutateAsync(entryId);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
},
[_cloneMutation],
);
/**
* Calls mutation to update existing entry
* @private
@@ -231,6 +206,7 @@ export const useEntryActions = () => {
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -395,6 +371,7 @@ export const useEntryActions = () => {
onError: (_error, _newEvent, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousRundown);
},
networkMode: 'always',
});
const batchUpdateEvents = useCallback(
@@ -449,6 +426,7 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -502,6 +480,7 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -525,13 +504,14 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Applies a given delay
*/
const applyDelay = useCallback(
async (delayEventId: EntryId) => {
async (delayEventId: string) => {
try {
await _applyDelayMutation.mutateAsync(delayEventId);
} catch (error) {
@@ -541,101 +521,59 @@ export const useEntryActions = () => {
[_applyDelayMutation],
);
/**
* Calls mutation to dissolve a block
* @private
*/
const _ungroupMutation = useMutation({
mutationFn: requestUngroup,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Deletes a block and moves its events to the top level
*/
const ungroup = useCallback(
async (blockId: EntryId) => {
try {
await _ungroupMutation.mutateAsync(blockId);
} catch (error) {
logAxiosError('Error dissolving block', error);
}
},
[_ungroupMutation],
);
/**
* Calls mutation to create a block with a selection
* @private
*/
const _groupEntriesMutation = useMutation({
mutationFn: requestGroupEntries,
onSuccess: (response) => {
if (!response.data) return;
const { id, title, order, flatOrder, entries, revision } = response.data;
queryClient.setQueryData<Rundown>(RUNDOWN, {
id,
title,
order,
flatOrder,
entries,
revision,
});
},
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
/**
* Create a block with a selection
*/
const groupEntries = useCallback(
async (entryIds: EntryId[]) => {
try {
await _groupEntriesMutation.mutateAsync(entryIds);
} catch (error) {
logAxiosError('Error grouping entries', error);
}
},
[_groupEntriesMutation],
);
/**
* Calls mutation to reorder an entry
* @private
*/
const _reorderEntryMutation = useMutation({
mutationFn: patchReorderEntry,
// we optimistically update here
onMutate: async (data) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
// Snapshot the previous value
const previousData = queryClient.getQueryData<Rundown>(RUNDOWN);
if (previousData) {
// optimistically update object
const newOrder = reorderArray(previousData.order, data.from, data.to);
queryClient.setQueryData<Rundown>(RUNDOWN, {
id: previousData.id,
title: previousData.title,
order: newOrder,
flatOrder: previousData.flatOrder,
entries: previousData.entries,
revision: -1,
});
}
// Return a context with the previous and new events
return { previousData };
},
// Mutation fails, rollback undoes optimist update
onError: (_error, _data, context) => {
queryClient.setQueryData<Rundown>(RUNDOWN, context?.previousData);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
* Reorders a given entry
*/
const reorderEntry = useCallback(
async (entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') => {
async (entryId: string, from: number, to: number) => {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order,
eventId: entryId,
from,
to,
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
@@ -645,30 +583,6 @@ export const useEntryActions = () => {
[_reorderEntryMutation],
);
const move = useCallback(async (entryId: EntryId, direction: 'up' | 'down') => {
const cachedRundown = queryClient.getQueryData<Rundown>(RUNDOWN);
if (!cachedRundown?.order) {
return;
}
const { destinationId, order } =
direction === 'up'
? moveUp(entryId, cachedRundown.order, cachedRundown.entries)
: moveDown(entryId, cachedRundown.order, cachedRundown.entries);
if (destinationId) {
try {
const reorderObject: ReorderEntry = {
entryId,
destinationId,
order: order as 'before' | 'after' | 'insert',
};
await _reorderEntryMutation.mutateAsync(reorderObject);
} catch (error) {
logAxiosError('Error re-ordering event', error);
}
}
}, []);
/**
* Calls mutation to swap events
* @private
@@ -719,6 +633,7 @@ export const useEntryActions = () => {
onSettled: () => {
queryClient.invalidateQueries({ queryKey: RUNDOWN });
},
networkMode: 'always',
});
/**
@@ -739,13 +654,9 @@ export const useEntryActions = () => {
addEntry,
applyDelay,
batchUpdateEvents,
clone,
deleteEntry,
deleteAllEntries,
ungroup,
getEntryById,
groupEntries,
move,
reorderEntry,
swapEvents,
updateEntry,
@@ -768,11 +679,12 @@ function optimisticDeleteEntries(entryIds: EntryId[], rundown: Rundown) {
}
function deleteEntry(entry: OntimeEntry) {
if (isOntimeBlock(entry) || !entry.parent) {
order = order.filter((id) => id !== entry.id);
} else {
if (isOntimeEvent(entry) && entry.parent) {
const parent = entries[entry.parent] as OntimeBlock;
parent.events = parent.events.filter((event) => event !== entry.id);
parent.numEvents -= 1;
} else {
order = order.filter((id) => id !== entry.id);
}
delete entries[entry.id];
-11
View File
@@ -1,20 +1,9 @@
import { QueryClient } from '@tanstack/react-query';
import { isOntimeCloud } from '../externals';
export const ontimeQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 10, // 10 min
},
mutations: {
/**
* React Query detects whether the client is online
* However, web access is not required for the clients when deployed locally
* - use 'always' for clients that may be online
* - use 'online' for clients that are connected to the cloud
*/
networkMode: isOntimeCloud ? 'online' : 'always',
},
},
});
@@ -1,6 +1,6 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
+8 -1
View File
@@ -10,6 +10,7 @@ import AppSettings from '../app-settings/AppSettings';
import useAppSettingsNavigation from '../app-settings/useAppSettingsNavigation';
import { EditorOverview } from '../overview/Overview';
import Finder from './finder/Finder';
import WelcomePlacement from './welcome/WelcomePlacement';
import styles from './Editor.module.scss';
@@ -21,6 +22,7 @@ const MessageControl = lazy(() => import('../control/message/MessageControlExpor
export default function Editor() {
const { isOpen: isSettingsOpen, setLocation, close } = useAppSettingsNavigation();
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isFinderOpen, onToggle: onFinderToggle, onClose: onFinderClose } = useDisclosure();
useWindowTitle('Editor');
@@ -44,11 +46,16 @@ export default function Editor() {
}
}, [close, isSettingsOpen, setLocation]);
useHotkeys([['mod + ,', toggleSettings]]);
useHotkeys([
['mod + ,', toggleSettings],
['mod + f', onFinderToggle],
['Escape', onFinderClose],
]);
return (
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview>
<IconButton
@@ -2,10 +2,3 @@
padding-block: 1.5rem;
text-align: center;
}
.inline {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
}
+28 -52
View File
@@ -37,12 +37,12 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { cloneEvent } from '../../common/utils/eventsManager';
import BlockBlock from './block-block/BlockBlock';
import BlockEnd from './block-block/BlockEnd';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from './rundown.utils';
import { makeRundownMetadata, makeSortableList } from './rundown.utils';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
@@ -55,10 +55,10 @@ interface RundownProps {
}
export default function Rundown({ data }: RundownProps) {
const { order, entries, id } = data;
const { order, flatOrder, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(order, entries));
const [sortableData, setSortableData] = useState<EntryId[]>(() => makeSortableList(flatOrder, entries));
const [collapsedGroups, setCollapsedGroups] = useSessionStorage<EntryId[]>({
// we ensure that this is unique to the rundown
key: `rundown.${id}-editor-collapsed-groups`,
@@ -78,7 +78,7 @@ export default function Rundown({ data }: RundownProps) {
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
// DND KIT
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } }));
const sensors = useSensors(useSensor(PointerSensor));
const deleteAtCursor = useCallback(
(cursor: string | null) => {
@@ -188,26 +188,19 @@ export default function Rundown({ data }: RundownProps) {
);
const moveEntry = useCallback(
(cursor: EntryId | null, direction: 'up' | 'down') => {
if (sortableData.length < 2 || cursor == null) {
(cursor: string | null, direction: 'up' | 'down') => {
if (order.length < 2 || cursor == null) {
return;
}
const { index } =
direction === 'up' ? getPreviousNormal(entries, order, cursor) : getNextNormal(entries, order, cursor);
const { destinationId, order, isBlock } =
direction === 'up' ? moveUp(cursor, sortableData, entries) : moveDown(cursor, sortableData, entries);
if (!destinationId) {
return;
if (index !== null) {
const offsetIndex = direction === 'up' ? index + 1 : index - 1;
reorderEntry(cursor, offsetIndex, index);
}
// if we are moving into a block, we need to make sure it is expanded
if (isBlock) {
handleCollapseGroup(false, destinationId);
}
reorderEntry(cursor, destinationId, order as 'before' | 'after' | 'insert');
},
[sortableData, reorderEntry],
[order, reorderEntry, entries],
);
// shortcuts
@@ -244,8 +237,8 @@ export default function Rundown({ data }: RundownProps) {
// we copy the state from the store here
// to workaround async updates on the drag mutations
useEffect(() => {
setSortableData(makeSortableList(order, entries));
}, [order, entries]);
setSortableData(makeSortableList(flatOrder, entries));
}, [flatOrder, entries]);
// in run mode, we follow selection
useEffect(() => {
@@ -292,33 +285,18 @@ export default function Rundown({ data }: RundownProps) {
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over?.id || active.id === over.id) {
return;
if (over?.id) {
if (active.id !== over?.id) {
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
reorderEntry(String(active.id), fromIndex, toIndex);
}
}
const fromIndex = active.data.current?.sortable.index;
const toIndex = over.data.current?.sortable.index;
// we keep a copy of the state as a hack to handle inconsistencies between dnd-kit and async store updates
setSortableData((currentEntries) => {
return reorderArray(currentEntries, fromIndex, toIndex);
});
let destinationId = over.id as EntryId;
let order: 'before' | 'after' | 'insert' = fromIndex < toIndex ? 'after' : 'before';
/**
* We need to specially handle the end blocks
* Dragging before and end block will add the entry to the end of the block
* Dragging after an end block will add the event after the block itself
*/
if (destinationId.startsWith('end-')) {
destinationId = destinationId.replace('end-', '');
// if we are moving before the end, we use the insert operation
order = 'insert';
}
reorderEntry(active.id as EntryId, destinationId, order);
};
/**
@@ -351,7 +329,7 @@ export default function Rundown({ data }: RundownProps) {
};
if (sortableData.length < 1) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
return <RundownEmpty handleAddNew={() => insertAtId({ type: SupportedEntry.Event }, cursor)} />;
}
// 1. gather presentation options
@@ -384,8 +362,6 @@ export default function Rundown({ data }: RundownProps) {
if (isBlockCollapsed && isEditMode && isLast) {
return <QuickAddBlock key={entryId} previousEventId={parentId} parentBlock={null} />;
} else if (isBlockCollapsed) {
return null;
} else {
const parentColour = (entries[parentId] as OntimeBlock | undefined)?.colour;
// if the previous element is selected, it will have its own QuickAddBlock
@@ -396,7 +372,7 @@ export default function Rundown({ data }: RundownProps) {
<Fragment key={entryId}>
{showPrependingQuickAdd && (
<QuickAddBlock
previousEventId={rundownMetadata.thisId}
previousEventId={rundownMetadata.previousEntryId}
parentBlock={parentId}
backgroundColor={parentColour}
/>
@@ -1,13 +1,12 @@
import { IoAdd } from 'react-icons/io5';
import { Button } from '@chakra-ui/react';
import { SupportedEntry } from 'ontime-types';
import Empty from '../../common/components/state/Empty';
import style from './Empty.module.scss';
interface RundownEmptyProps {
handleAddNew: (type: SupportedEntry) => void;
handleAddNew: () => void;
}
export default function RundownEmpty(props: RundownEmptyProps) {
@@ -15,16 +14,10 @@ export default function RundownEmpty(props: RundownEmptyProps) {
return (
<div className={style.empty}>
<Empty style={{ marginTop: '5vh', marginBottom: '3rem' }} />
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event
</Button>
<Button onClick={() => handleAddNew(SupportedEntry.Block)} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Block
</Button>
</div>
<Empty style={{ marginTop: '7vh', marginBottom: '1.5rem' }} />
<Button onClick={handleAddNew} variant='ontime-filled' leftIcon={<IoAdd />}>
Create Event
</Button>
</div>
);
}
@@ -12,24 +12,25 @@ import {
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/clone';
import { cloneEvent } from '../../common/utils/eventsManager';
import DelayBlock from './delay-block/DelayBlock';
import EventBlock from './event-block/EventBlock';
import { useEventSelection } from './useEventSelection';
export type EventItemActions =
| 'set-cursor'
| 'event'
| 'event-before'
| 'delay'
| 'delay-before'
| 'block'
| 'block-before'
| 'swap'
| 'delete'
| 'clone'
| 'group'
| 'update';
| 'update'
| 'swap'
| 'clear-report';
interface RundownEntryProps {
type: SupportedEntry;
@@ -65,7 +66,7 @@ export default function RundownEntry(props: RundownEntryProps) {
isLinkedToLoaded,
} = props;
const { emitError } = useEmitLog();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { addEntry, updateEntry, batchUpdateEvents, deleteEntry, swapEvents } = useEntryActions();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
@@ -128,13 +129,6 @@ export default function RundownEntry(props: RundownEntryProps) {
addEntry(newEvent, { after: data.id });
break;
}
case 'group': {
if (selectedEvents.size > 1) {
clearMultiSelection();
return groupEntries(Array.from(selectedEvents));
}
break;
}
case 'update': {
// Handles and filters update requests
const { field, value } = payload as FieldValue;
@@ -8,14 +8,11 @@ import { cx } from '../../common/utils/styleUtils';
import { Corner } from '../editors/editor-utils/EditorUtils';
import RundownEventEditor from './event-editor/RundownEventEditor';
import FinderPlacement from './placements/FinderPlacement';
import RundownWrapper from './RundownWrapper';
import style from './RundownExport.module.scss';
export default memo(RundownExport);
function RundownExport() {
const RundownExport = () => {
const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run';
@@ -24,7 +21,6 @@ function RundownExport() {
return (
<div className={classes} data-testid='panel-rundown'>
<FinderPlacement />
<div className={style.rundown}>
<div className={style.list}>
<ErrorBoundary>
@@ -44,4 +40,6 @@ function RundownExport() {
</div>
</div>
);
}
};
export default memo(RundownExport);
@@ -1,6 +1,6 @@
import { EntryId, OntimeBlock, OntimeDelay, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { OntimeBlock, OntimeEvent, RundownEntries, SupportedEntry } from 'ontime-types';
import { makeRundownMetadata, makeSortableList, moveDown, moveUp } from '../rundown.utils';
import { makeRundownMetadata, makeSortableList } from '../rundown.utils';
describe('makeRundownMetadata()', () => {
it('processes nested rundown data', () => {
@@ -21,7 +21,7 @@ describe('makeRundownMetadata()', () => {
block: {
id: 'block',
type: SupportedEntry.Block,
events: ['11', 'delay', '12', '13'],
events: ['11', '12', '13'],
colour: 'red',
} as OntimeBlock,
'11': {
@@ -36,12 +36,6 @@ describe('makeRundownMetadata()', () => {
skip: false,
linkStart: false,
} as OntimeEvent,
delay: {
id: 'delay',
type: SupportedEntry.Delay,
parent: 'block',
duration: 0,
} as OntimeDelay,
'12': {
id: '12',
type: SupportedEntry.Event,
@@ -142,25 +136,10 @@ describe('makeRundownMetadata()', () => {
groupColour: 'red',
});
expect(process(demoEvents['delay'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['11'],
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['delay'].id,
eventIndex: 2,
isPast: true,
isNextDay: false,
totalGap: 10,
isLinkedToLoaded: false,
isLoaded: false,
groupId: 'block',
groupColour: 'red',
});
expect(process(demoEvents['12'])).toMatchObject({
previousEvent: demoEvents['11'],
latestEvent: demoEvents['12'],
previousEntryId: demoEvents['delay'].id,
previousEntryId: demoEvents['11'].id,
thisId: demoEvents['12'].id,
eventIndex: 3,
isPast: false,
@@ -286,7 +265,7 @@ describe('makeRundownMetadata()', () => {
describe('makeSortableList()', () => {
it('generates a list with block ends', () => {
const order = ['block-1', '2', 'block-3', 'block-4'];
const flatOrder = ['block-1', '11', '2', 'block-3', '31', 'block-4'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: ['11'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
@@ -296,8 +275,8 @@ describe('makeSortableList()', () => {
'block-4': { type: SupportedEntry.Block, id: 'block-4', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
expect(sortableList).toStrictEqual([
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toEqual([
'block-1',
'11',
'end-block-1',
@@ -311,89 +290,25 @@ describe('makeSortableList()', () => {
});
it('closes dangling blocks', () => {
const order = ['block'];
const flatOrder = ['block', '11', '12'];
const entries: RundownEntries = {
block: { type: SupportedEntry.Block, id: 'block-1', events: ['11', '12'] } as OntimeBlock,
'11': { type: SupportedEntry.Event, id: '11', parent: 'block-1' } as OntimeEvent,
'12': { type: SupportedEntry.Event, id: '12', parent: 'block-1' } as OntimeEvent,
};
const sortableList = makeSortableList(order, entries);
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', '11', '12', 'end-block-1']);
});
it('handles a list with a with just blocks', () => {
const order = ['block-1', 'block-2'];
const flatOrder = ['block-1', 'block-2'];
const entries: RundownEntries = {
'block-1': { type: SupportedEntry.Block, id: 'block-1', events: [] as string[] } as OntimeBlock,
'block-2': { type: SupportedEntry.Block, id: 'block-2', events: [] as string[] } as OntimeBlock,
};
const sortableList = makeSortableList(order, entries);
const sortableList = makeSortableList(flatOrder, entries);
expect(sortableList).toStrictEqual(['block-1', 'end-block-1', 'block-2', 'end-block-2']);
});
});
describe('moveUp()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event3'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event up in the list', () => {
const result = moveUp('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event1', order: 'before', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveUp('block2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block1', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveUp('event3', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'insert', isBlock: true });
})
it('moving up from top is noop', () => {
const result = moveUp('event1', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
describe('moveDown()', () => {
const sortableData = ['event1', 'event2', 'block1', 'event11', 'end-block1', 'block2', 'end-block2', 'event3'];
const entries = {
event1: { type: 'event', id: 'event1', parent: null } as OntimeEvent,
event2: { type: 'event', id: 'event2', parent: null }as OntimeEvent,
block1: { type: 'block', id: 'block1', events: ['event11'] } as OntimeBlock,
event11: { type: 'event', id: 'event11', parent: 'block1' } as OntimeEvent,
block2: { type: 'block', id: 'block2', events: [] as EntryId[] } as OntimeBlock,
event3: { type: 'event', id: 'event3', parent: null } as OntimeEvent,
};
it('moves an event down in the list', () => {
const result = moveDown('event1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event2', order: 'after', isBlock: false });
})
it.todo('disallows nesting blocks', () => {
const result = moveDown('block1', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'block2', order: 'before', isBlock: false });
})
it('moves an event into a block', () => {
const result = moveDown('event2', sortableData, entries);
expect(result).toStrictEqual({ destinationId: 'event11', order: 'before', isBlock: true });
})
it('moving down from bottom is noop', () => {
const result = moveDown('event3', sortableData, entries);
expect(result).toMatchObject({ destinationId: null });
})
});
@@ -1,19 +1,9 @@
import { useRef } from 'react';
import {
IoChevronDown,
IoChevronUp,
IoDuplicateOutline,
IoFolderOpenOutline,
IoReorderTwo,
IoTrash,
} from 'react-icons/io5';
import { IconButton } from '@chakra-ui/react';
import { IoChevronDown, IoChevronUp, IoReorderTwo } from 'react-icons/io5';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { EntryId, OntimeBlock } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration, formatTime } from '../../../common/utils/time';
import EditableBlockTitle from '../common/EditableBlockTitle';
@@ -31,27 +21,6 @@ interface BlockBlockProps {
export default function BlockBlock(props: BlockBlockProps) {
const { data, hasCursor, collapsed, onCollapse } = props;
const handleRef = useRef<null | HTMLSpanElement>(null);
const { clone, ungroup, deleteEntry } = useEntryActions();
const [onContextMenu] = useContextMenu<HTMLDivElement>([
{
label: 'Clone Block',
icon: IoDuplicateOutline,
onClick: () => clone(data.id),
},
{
label: 'Ungroup',
icon: IoFolderOpenOutline,
onClick: () => ungroup(data.id),
isDisabled: data.events.length === 0,
},
{
label: 'Delete Block',
icon: IoTrash,
onClick: () => deleteEntry([data.id]),
withDivider: true,
},
]);
const {
attributes: dragAttributes,
@@ -84,7 +53,6 @@ export default function BlockBlock(props: BlockBlockProps) {
<div
className={cx([style.block, hasCursor && style.hasCursor, !collapsed && style.expanded])}
ref={setNodeRef}
onContextMenu={onContextMenu}
style={{
...(binderColours ? { '--user-bg': binderColours.backgroundColor } : {}),
...dragStyle,
@@ -103,15 +71,9 @@ export default function BlockBlock(props: BlockBlockProps) {
<div className={style.header}>
<div className={style.titleRow}>
<EditableBlockTitle title={data.title} eventId={data.id} placeholder='Block title' />
<IconButton
aria-label='Collapse'
onClick={() => onCollapse(!collapsed, data.id)}
color='#e2e2e2' // $gray-200
variant='ontime-ghosted'
size='sm'
>
<button onClick={() => onCollapse(!collapsed, data.id)}>
{collapsed ? <IoChevronUp /> : <IoChevronDown />}
</IconButton>
</button>
</div>
<div className={style.metaRow}>
<div className={style.metaEntry}>
@@ -128,7 +90,7 @@ export default function BlockBlock(props: BlockBlockProps) {
</div>
<div className={style.metaEntry}>
<div>Events</div>
<div>{data.events.length}</div>
<div>{data.numEvents}</div>
</div>
</div>
</div>
@@ -2,7 +2,6 @@ import { MouseEvent, useEffect, useLayoutEffect, useRef, useState } from 'react'
import {
IoAdd,
IoDuplicateOutline,
IoFolder,
IoLink,
IoPeople,
IoPeopleOutline,
@@ -27,7 +26,7 @@ import RundownIndicators from './RundownIndicators';
import style from './EventBlock.module.scss';
interface EventBlockProps {
eventId: EntryId;
eventId: string;
cue: string;
timeStart: number;
timeEnd: number;
@@ -145,7 +144,6 @@ export default function EventBlock(props: EventBlockProps) {
value: false,
}),
},
{ withDivider: true, label: 'Group', icon: IoFolder, onClick: () => actionHandler('group') },
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
@@ -72,6 +72,7 @@ function EventTriggerForm(props: EventTriggerFormProps) {
variant='ontime'
value={cycleValue}
onChange={(e) => setCycleValue(e.target.value as TimerLifeCycle)}
defaultValue={TimerLifeCycle.onStart}
>
<option disabled>Lifecycle Trigger</option>
{eventTriggerOptions.map((cycle) => (
@@ -1,22 +0,0 @@
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { useHotkeys } from '@mantine/hooks';
import Finder from '../../editors/finder/Finder';
export default memo(FinderPlacement);
function FinderPlacement() {
const { isOpen, onToggle, onClose } = useDisclosure();
useHotkeys([
['mod + f', onToggle],
['Escape', onClose],
]);
if (isOpen) {
return <Finder isOpen={isOpen} onClose={onClose} />;
}
return null;
}
+29 -114
View File
@@ -126,29 +126,44 @@ function processEntry(
* Due to limitations in dnd-kit we need to flatten the list of entries
* This list should also be aware of any elements that are sortable (ie: block ends)
*/
export function makeSortableList(order: EntryId[], entries: RundownEntries): EntryId[] {
const flatIds: EntryId[] = [];
export function makeSortableList(flatOrder: EntryId[], entries: RundownEntries): EntryId[] {
const entryIds: EntryId[] = [];
let lastSeenBlock: MaybeString = null;
for (let i = 0; i < order.length; i++) {
const entry = entries[order[i]];
for (let i = 0; i < flatOrder.length; i++) {
const entry = entries[flatOrder[i]];
if (!entry) {
continue;
}
if (isOntimeBlock(entry)) {
// inside a block there are delays and events
// there is no need for special handling
flatIds.push(entry.id);
flatIds.push(...entry.events);
// close the block
flatIds.push(`end-${entry.id}`);
} else {
flatIds.push(entry.id);
// close any previous blocks
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.id;
}
if (isOntimeEvent(entry)) {
// Close the previous block if the parent changes
if (lastSeenBlock !== null && entry.parent !== lastSeenBlock) {
entryIds.push(`end-${lastSeenBlock}`);
}
lastSeenBlock = entry.parent;
}
entryIds.push(entry.id);
}
return flatIds;
// double check that we close any dangling blocks
// - if the last element is a block
// - if a rundown only has a top level block
if (lastSeenBlock !== null) {
entryIds.push(`end-${lastSeenBlock}`);
}
return entryIds;
}
/**
@@ -163,103 +178,3 @@ export function canDrop(targetType?: SupportedEntry, targetParent?: EntryId | nu
// we can swap places with other blocks
return targetType == 'block';
}
/**
* Calculates destinations for an entry moving one position up in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveUp(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const previousEntryId = getPreviousId(entryId, sortableData);
// the user is moving up at the top of the list
if (!previousEntryId) {
return { destinationId: null, order: 'before', isBlock: false };
}
if (previousEntryId.startsWith('end-')) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// if we are moving a block, we cannot insert it
return { destinationId: previousEntryId.replace('end-', ''), order: 'before', isBlock: false };
}
// insert in the block ID will add to the end of the block events
return { destinationId: previousEntryId.replace('end-', ''), order: 'insert', isBlock: true };
}
// @ts-expect-error -- we safeguard the entry not having a parent property
return { destinationId: previousEntryId, order: 'before', isBlock: Boolean(entries[previousEntryId]?.parent) };
}
/**
* Calculates destinations for an entry moving one position down in the rundown
* - Handles noops
* - Handles moving in and out of blocks
* TODO: handle moving blocks
*/
export function moveDown(entryId: EntryId, sortableData: EntryId[], entries: RundownEntries) {
const nextEntryId = getNextId(entryId, sortableData);
// the user is moving down at the end of the list
if (!nextEntryId) {
return { destinationId: null, order: 'after', isBlock: false };
}
if (nextEntryId.startsWith('end-')) {
// move outside the block
return { destinationId: nextEntryId.replace('end-', ''), order: 'after', isBlock: false };
}
/**
* If the next entry is a block
* - 1. blocks need to skip over it
* - 2. if the block has children, we insert before the first child
* - 3. if the block is empty, we insert into the block
*/
if (isOntimeBlock(entries[nextEntryId])) {
const entry = entries[entryId];
if (isOntimeBlock(entry)) {
// 1. if we are moving a block, we cannot insert it
return { destinationId: nextEntryId, order: 'after', isBlock: false };
}
const firstBlockChild = entries[nextEntryId].events.at(0);
if (firstBlockChild) {
// 2. add before the first child of the block
return { destinationId: firstBlockChild, order: 'before', isBlock: true };
} else {
// 3. or insert into an empty block
return { destinationId: nextEntryId, order: 'insert', isBlock: true };
}
}
return { destinationId: nextEntryId, order: 'after', isBlock: Boolean(entries[nextEntryId]?.parent) };
}
/**
* Utility function gets the ID if the next entry in the list
* returns null if none is found
*/
function getNextId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex === -1 || currentIndex === sortableData.length - 1) {
// No next ID if not found or at the end
return null;
}
return sortableData[currentIndex + 1];
}
/**
* Utility function gets the ID if the previous entry in the list
* returns null if none is found
*/
function getPreviousId(entryId: EntryId, sortableData: EntryId[]): EntryId | null {
const currentIndex = sortableData.indexOf(entryId);
if (currentIndex < 1) {
// No previous ID found or at the beginning
return null;
}
return sortableData[currentIndex - 1];
}
@@ -9,13 +9,13 @@ import { isMacOS } from '../../common/utils/deviceUtils';
type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<EntryId>;
selectedEvents: Set<string>;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: EntryId; index: number; selectMode: SelectionMode }) => void;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: EntryId) => void;
unselect: (id: string) => void;
}
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
@@ -81,7 +81,7 @@ export function getFormattedTimer(
localisedMinutes: string,
options: FormattingOptions,
): string {
if (timer == null) {
if (timer == null || timerType === TimerType.None) {
return options.removeSeconds ? timerPlaceholderMin : timerPlaceholder;
}
@@ -98,7 +98,7 @@ export function getFormattedTimer(
}
}
let display = millisToString(timeToParse);
let display = millisToString(timeToParse, { direction: timerType });
if (options.removeLeadingZero) {
display = removeLeadingZero(display);
}
@@ -3,7 +3,7 @@ import { MenuDivider, MenuItem, MenuList } from '@chakra-ui/react';
import { isOntimeEvent, SupportedEntry } from 'ontime-types';
import { useEntryActions } from '../../../../common/hooks/useEntryAction';
import { cloneEvent } from '../../../../common/utils/clone';
import { cloneEvent } from '../../../../common/utils/eventsManager';
interface CuesheetTableMenuActionsProps {
eventId: string;
@@ -13,7 +13,7 @@ interface CuesheetTableMenuActionsProps {
export default function CuesheetTableMenuActions(props: CuesheetTableMenuActionsProps) {
const { eventId, entryIndex, showModal } = props;
const { addEntry, getEntryById, move, deleteEntry } = useEntryActions();
const { addEntry, getEntryById, reorderEntry, deleteEntry } = useEntryActions();
const handleCloneEvent = () => {
const currentEvent = getEntryById(eventId);
@@ -45,10 +45,14 @@ export default function CuesheetTableMenuActions(props: CuesheetTableMenuActions
Clone event
</MenuItem>
<MenuDivider />
<MenuItem isDisabled={entryIndex < 1} icon={<IoArrowUp />} onClick={() => move(eventId, 'up')}>
<MenuItem
isDisabled={entryIndex < 1}
icon={<IoArrowUp />}
onClick={() => reorderEntry(eventId, entryIndex, entryIndex - 1)}
>
Move up
</MenuItem>
<MenuItem icon={<IoArrowDown />} onClick={() => move(eventId, 'down')}>
<MenuItem icon={<IoArrowDown />} onClick={() => reorderEntry(eventId, entryIndex, entryIndex + 1)}>
Move down
</MenuItem>
<MenuItem icon={<IoTrash />} onClick={() => deleteEntry([eventId])}>
-1
View File
@@ -6,7 +6,6 @@
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"classic-level": "^3.0.0",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
-5
View File
@@ -1,10 +1,5 @@
import { socket } from './WebsocketAdapter.js';
export enum RefetchTargets {
Rundown = 'rundown',
Report = 'report',
}
/**
* Utility function to notify clients that the REST data is stale
* @param payload -- possible patch payload
@@ -1,10 +1,10 @@
import express from 'express';
import { getAll, deleteWithId, deleteAll } from './report.controller.js';
import { paramsMustHaveEntryId } from '../rundown/rundown.validation.js';
import { paramsMustHaveEventId } from '../rundown/rundown.validation.js';
export const router = express.Router();
router.get('/', getAll);
router.delete('/all', deleteAll);
router.delete('/:eventId', paramsMustHaveEntryId, deleteWithId);
router.delete('/:eventId', paramsMustHaveEventId, deleteWithId);
@@ -1,6 +1,6 @@
import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types';
import { RuntimeState } from '../../stores/runtimeState.js';
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { DeepReadonly } from 'ts-essentials';
const report = new Map<string, OntimeEventReport>();
@@ -58,7 +58,7 @@ export function triggerReportEntry(
report.set(eventId, { startedAt, endedAt: state.clock });
formattedReport = null;
sendRefetch({
target: RefetchTargets.Report,
target: 'REPORT',
});
}
}
@@ -11,10 +11,8 @@ import {
deleteAllEntries,
deleteEvent,
editEvent,
ungroupEntries,
groupEntries,
reorderEntry,
swapEvents,
cloneEntry,
} from '../../services/rundown-service/RundownService.js';
import { getEntryWithId, getCurrentRundown } from '../../services/rundown-service/rundownUtils.js';
@@ -88,6 +86,21 @@ export async function rundownBatchPut(req: Request, res: Response<MessageRespons
}
}
export async function rundownReorder(req: Request, res: Response<OntimeEntry | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const { eventId, from, to } = req.body;
const event = await reorderEntry(eventId, from, to);
res.status(200).send(event.newEvent);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownSwap(req: Request, res: Response<MessageResponse | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
@@ -105,7 +118,7 @@ export async function rundownSwap(req: Request, res: Response<MessageResponse |
export async function rundownApplyDelay(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await applyDelay(req.params.entryId);
await applyDelay(req.params.eventId);
res.status(200).send({ message: 'Delay applied' });
} catch (error) {
const message = getErrorMessage(error);
@@ -113,36 +126,6 @@ export async function rundownApplyDelay(req: Request, res: Response<MessageRespo
}
}
export async function rundownCloneEntry(req: Request, res: Response<Rundown | ErrorResponse>) {
try {
const newRundown = await cloneEntry(req.params.entryId);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownUngroupEntries(req: Request, res: Response<Rundown | ErrorResponse>) {
try {
const newRundown = await ungroupEntries(req.params.entryId);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownAddToBlock(req: Request, res: Response<Rundown | ErrorResponse>) {
try {
const newRundown = await groupEntries(req.body.ids);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export async function rundownDelete(_req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteAllEntries();
@@ -1,28 +1,20 @@
import { ErrorResponse, Rundown } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import express from 'express';
import { reorderEntry } from '../../services/rundown-service/RundownService.js';
import {
deletesEventById,
rundownAddToBlock,
rundownApplyDelay,
rundownBatchPut,
rundownCloneEntry,
rundownDelete,
rundownUngroupEntries,
rundownGetAll,
rundownGetById,
rundownGetCurrent,
rundownPost,
rundownPut,
rundownReorder,
rundownSwap,
} from './rundown.controller.js';
import {
paramsMustHaveEntryId,
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownPostValidator,
@@ -35,28 +27,16 @@ export const router = express.Router();
router.get('/', rundownGetAll);
router.get('/current', rundownGetCurrent);
router.get('/:eventId', paramsMustHaveEntryId, rundownGetById); // not used in Ontime frontend
router.get('/:eventId', paramsMustHaveEventId, rundownGetById); // not used in Ontime frontend
router.post('/', rundownPostValidator, rundownPost);
router.put('/', rundownPutValidator, rundownPut);
router.put('/batch', rundownBatchPutValidator, rundownBatchPut);
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const { entryId, destinationId, order } = req.body;
const newRundown = await reorderEntry(entryId, destinationId, order);
res.status(200).send(newRundown);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
});
router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:entryId', paramsMustHaveEntryId, rundownApplyDelay);
router.post('/clone/:entryId', paramsMustHaveEntryId, rundownCloneEntry);
router.post('/ungroup/:entryId', paramsMustHaveEntryId, rundownUngroupEntries);
router.post('/group', rundownArrayOfIds, rundownAddToBlock);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
@@ -1,7 +1,7 @@
import { OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { generateId, validateEndAction, validateTimerType, validateTimes } from 'ontime-utils';
import { event as eventDef, block as blockDef } from '../../models/eventsDefinition.js';
import { event as eventDef } from '../../models/eventsDefinition.js';
import { makeString } from '../../utils/parserUtils.js';
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
@@ -68,31 +68,6 @@ export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number
return event;
};
/**
* Creates a new block from an optional patch
*/
export function createBlock(patch?: Partial<OntimeBlock>): OntimeBlock {
if (!patch) {
return { ...blockDef, id: generateId() };
}
return {
id: patch.id ?? generateId(),
type: SupportedEntry.Block,
title: patch.title ?? '',
note: patch.note ?? '',
events: patch.events ?? [],
skip: patch.skip ?? false,
colour: makeString(patch.colour, ''),
custom: patch.custom ?? {},
revision: 0,
startTime: null,
endTime: null,
duration: 0,
isFirstLinked: false,
};
}
/**
* Function infers strategy for a patch with only partial timer data
* @param end
@@ -35,9 +35,9 @@ export const rundownBatchPutValidator = [
];
export const rundownReorderValidator = [
body('entryId').isString().exists(),
body('destinationId').isString().exists(),
body('order').isIn(['before', 'after', 'insert']).exists(),
body('eventId').isString().exists(),
body('from').isNumeric().exists(),
body('to').isNumeric().exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -57,8 +57,8 @@ export const rundownSwapValidator = [
},
];
export const paramsMustHaveEntryId = [
param('entryId').exists(),
export const paramsMustHaveEventId = [
param('eventId').exists(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -68,7 +68,7 @@ export const paramsMustHaveEntryId = [
];
export const rundownArrayOfIds = [
body('ids').isArray().notEmpty(),
body('ids').isArray().exists(),
body('ids.*').isString(),
(req: Request, res: Response, next: NextFunction) => {
+17 -39
View File
@@ -243,69 +243,47 @@ export const startIntegrations = async () => {
* @param {number} exitCode
* @return {Promise<void>}
*/
const shutdown = (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code: ${exitCode}`);
// sync shutdowns
oscServer.shutdown();
socket.shutdown();
runtimeService.shutdown();
const shutdown = async (exitCode = 0) => {
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
// clear the restore file if it was a normal exit
// 0 means it was a SIGNAL
// 1 means crash -> keep the file
// 2 means dev crash -> do nothing
// 99 means there was a shutdown request from the UI
if (exitCode === 0 || exitCode === 99) {
await restoreService.clear();
}
const pendingRestoreService = new Promise((resolve, _reject) => {
if (exitCode === 0 || exitCode === 99) {
restoreService.clear().then(resolve);
}
resolve;
});
const pendingExpressServer = new Promise((resolve, _reject) => {
expressServer?.close(resolve);
});
const pendingDataProvider = new Promise((resolve, _reject) => {
getDataProvider().shutdown().then(resolve);
});
Promise.all([pendingRestoreService, pendingExpressServer, pendingDataProvider]);
expressServer?.close();
runtimeService.shutdown();
logger.shutdown();
expressServer?.close(() => {
getDataProvider()
.shutdown()
.then(() => {
process.exit(exitCode);
});
});
oscServer.shutdown();
socket.shutdown();
process.exit(exitCode);
};
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
process.on('unhandledRejection', (error) => {
process.on('unhandledRejection', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught rejection | ${error}`);
shutdown(1);
await shutdown(1);
});
process.on('uncaughtException', (error) => {
process.on('uncaughtException', async (error) => {
if (!isProduction && error instanceof Error && error.stack) {
consoleError(error.stack);
}
generateCrashReport(error);
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
shutdown(1);
await shutdown(1);
});
// register shutdown signals
process.once('SIGHUP', () => shutdown(0));
process.once('SIGINT', () => shutdown(0));
process.once('SIGTERM', () => shutdown(0));
process.once('SIGHUP', async () => shutdown(0));
process.once('SIGINT', async () => shutdown(0));
process.once('SIGTERM', async () => shutdown(0));
@@ -8,7 +8,6 @@ import {
AutomationSettings,
Rundown,
ProjectRundowns,
LogOrigin,
} from 'ontime-types';
import type { Low } from 'lowdb';
@@ -24,18 +23,6 @@ type ReadonlyPromise<T> = Promise<Readonly<T>>;
let db = {} as Low<DatabaseModel>;
import { publicDir } from '../../setup/index.js';
import { ClassicLevel } from 'classic-level';
import { logger } from '../Logger.js';
const main_db = new ClassicLevel<keyof DatabaseModel, any>(`${publicDir.projectsDir}/db`, {
valueEncoding: 'json',
});
const rundown_db = main_db.sublevel<string, Rundown>('rundowns', {
valueEncoding: 'json',
});
/**
* Initialises the JSON adapter to persist data to a file
*/
@@ -44,19 +31,6 @@ export async function initPersistence(filePath: string, fallbackData: DatabaseMo
DEV: shouldCrashDev(!isPath(filePath), 'initPersistence should be called with a path');
const newDb = await JSONFilePreset<DatabaseModel>(filePath, fallbackData);
const { project, settings, viewSettings, urlPresets, customFields, automation, rundowns } = fallbackData;
await main_db.open();
await main_db.put('project', project);
await main_db.put('settings', settings);
await main_db.put('viewSettings', viewSettings);
await main_db.put('urlPresets', urlPresets);
await main_db.put('customFields', customFields);
await main_db.put('automation', automation);
Object.entries(rundowns).forEach(([key, rundown]) => {
rundown_db.put(key, rundown);
});
// Read the database to initialize it
newDb.data = fallbackData;
await newDb.write();
@@ -86,7 +60,6 @@ export function getDataProvider() {
setAutomation,
getRundown,
mergeIntoData,
shutdown,
};
}
@@ -95,13 +68,13 @@ function getData(): Readonly<DatabaseModel> {
}
async function setProjectData(newData: Partial<ProjectData>): ReadonlyPromise<ProjectData> {
const newProjectData = { ...getProjectData(), ...newData };
await main_db.put('project', newProjectData);
return newProjectData;
db.data.project = { ...db.data.project, ...newData };
await persist();
return db.data.project;
}
function getProjectData(): ProjectData {
return main_db.getSync('project') as ProjectData;
function getProjectData(): Readonly<ProjectData> {
return db.data.project;
}
async function setCustomFields(newData: CustomFields): ReadonlyPromise<CustomFields> {
@@ -124,8 +97,8 @@ async function mergeRundown(
return { rundowns: db.data.rundowns, customFields: db.data.customFields };
}
function getCustomFields(): CustomFields {
return main_db.getSync('customFields') as CustomFields;
function getCustomFields(): Readonly<CustomFields> {
return db.data.customFields;
}
async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise<Rundown> {
@@ -134,8 +107,8 @@ async function setRundown(rundownKey: string, newData: Rundown): ReadonlyPromise
return db.data.rundowns[rundownKey];
}
function getSettings(): Settings {
return main_db.getSync('settings') as Settings;
function getSettings(): Readonly<Settings> {
return db.data.settings;
}
async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
@@ -144,8 +117,8 @@ async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
return db.data.settings;
}
function getUrlPresets(): URLPreset[] {
return main_db.getSync('urlPresets') as URLPreset[];
function getUrlPresets(): Readonly<URLPreset[]> {
return db.data.urlPresets;
}
async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]> {
@@ -154,8 +127,8 @@ async function setUrlPresets(newData: URLPreset[]): ReadonlyPromise<URLPreset[]>
return db.data.urlPresets;
}
function getViewSettings(): ViewSettings {
return main_db.getSync('viewSettings');
function getViewSettings(): Readonly<ViewSettings> {
return db.data.viewSettings;
}
async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSettings> {
@@ -164,10 +137,8 @@ async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSetti
return db.data.viewSettings;
}
function getAutomation(): AutomationSettings {
const automation = main_db.getSync('automation');
if (!automation) throw new Error('Failed to load automation from db');
return automation;
function getAutomation(): Readonly<AutomationSettings> {
return db.data.automation;
}
async function setAutomation(newData: AutomationSettings): ReadonlyPromise<AutomationSettings> {
@@ -176,10 +147,9 @@ async function setAutomation(newData: AutomationSettings): ReadonlyPromise<Autom
return db.data.automation;
}
function getRundown(): Rundown {
const rundown = rundown_db.getSync('default');
if (!rundown) throw new Error('Failed to load rundown from db');
return rundown;
function getRundown(): Readonly<Rundown> {
const firstRundown = Object.keys(db.data.rundowns)[0];
return db.data.rundowns[firstRundown];
}
async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<DatabaseModel> {
@@ -203,8 +173,3 @@ async function persist() {
if (isTest) return;
await db.write();
}
async function shutdown() {
logger.info(LogOrigin.Server, 'Closing DB');
await main_db.close();
}
@@ -1,24 +0,0 @@
export async function batchPutObject(obj: object, db) {
await db.batch(
Object.entries(obj).map(([key, value]) => {
return value === null ? { type: 'del', key } : { type: 'put', key, value };
}),
);
}
// await projectDb.batch([
// { type: 'put', key: 'title', value: project.title },
// { type: 'put', key: 'description', value: project.description },
// { type: 'put', key: 'publicUrl', value: project.publicUrl },
// { type: 'put', key: 'publicInfo', value: project.publicInfo },
// { type: 'put', key: 'backstageUrl', value: project.backstageUrl },
// { type: 'put', key: 'backstageInfo', value: project.backstageInfo },
// project.backstageInfo
// ? { type: 'put', key: 'projectLogo', value: project.projectLogo }
// : { type: 'del', key: 'projectLogo' },
// ]);
// // await levelDb.put('project', project);
// console.log('level',levelDb.getSync('project'));
// console.log('project',projectDb.getSync('title'));
+3
View File
@@ -52,6 +52,7 @@ export const demoDb: DatabaseModel = {
endTime: null,
duration: 0,
isFirstLinked: false,
numEvents: 0,
custom: {
song: 'Sekret',
artist: 'Ronela Hajati',
@@ -218,6 +219,7 @@ export const demoDb: DatabaseModel = {
endTime: null,
duration: 0,
isFirstLinked: false,
numEvents: 0,
},
'1c420': {
type: SupportedEntry.Event,
@@ -380,6 +382,7 @@ export const demoDb: DatabaseModel = {
endTime: null,
duration: 0,
isFirstLinked: false,
numEvents: 0,
},
'503c4': {
type: SupportedEntry.Event,
@@ -54,4 +54,5 @@ export const block: Omit<OntimeBlock, 'id'> = {
endTime: null, // calculated at runtime
duration: 0, // calculated at runtime
isFirstLinked: false, // calculated at runtime
numEvents: 0, // calculated at runtime
};
@@ -14,14 +14,14 @@ import {
} from 'ontime-types';
import { getCueCandidate } from 'ontime-utils';
import { delay as delayDef } from '../../models/eventsDefinition.js';
import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js';
import { createBlock, createEvent } from '../../api-data/rundown/rundown.utils.js';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { sendRefetch } from '../../adapters/websocketAux.js';
import { createEvent } from '../../api-data/rundown/rundown.utils.js';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import * as cache from './rundownCache.js';
import { getPreviousId } from './rundownUtils.js';
import { getInsertionPosition } from './rundownUtils.js';
type CompleteEntry<T> =
T extends Partial<OntimeEvent>
@@ -55,7 +55,7 @@ function generateEvent<T extends Partial<OntimeEvent> | Partial<OntimeDelay> | P
// TODO(v4): allow user to provide a larger patch of the block entry
if (isOntimeBlock(eventData)) {
return createBlock({ id, title: eventData.title ?? '' }) as CompleteEntry<T>;
return { ...blockDef, title: eventData?.title ?? '', id } as CompleteEntry<T>;
}
throw new Error('Invalid event type');
@@ -70,12 +70,11 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry
throw new Error(`Event with ID ${eventData.id} already exists`);
}
// 2. if the user provides a parent (inside a group), we make sure it exists and it is a group
// 2. if the user provides a parent (inside a group), we make sure it exists
let parent: EntryId | null = null;
if ('parent' in eventData && eventData.parent != null) {
const maybeParent = cache.getCurrentRundown().entries[eventData.parent];
if (!maybeParent || !isOntimeBlock(maybeParent)) {
throw new Error(`Invalid parent event with ID ${eventData.parent}`);
if (!cache.hasId(eventData.parent)) {
throw new Error(`Parent event with ID ${eventData.parent} not found`);
}
parent = eventData.parent;
}
@@ -92,14 +91,14 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry
}
}
const afterId = getPreviousId(eventData?.after, eventData?.before);
const { afterId, atIndex } = getInsertionPosition(parent, eventData?.after, eventData?.before);
// generate a fully formed entry from the patch
const sanitisedEntry = generateEvent(eventData, afterId);
// modify rundown
const scopedMutation = cache.mutateCache(cache.add);
const { newEvent } = await scopedMutation({ afterId, parent, entry: sanitisedEntry });
const { newEvent } = await scopedMutation({ atIndex, parent, entry: sanitisedEntry });
// notify runtime that rundown has changed
updateRuntimeOnChange();
@@ -114,9 +113,9 @@ export async function addEvent(eventData: EventPostPayload): Promise<OntimeEntry
/**
* deletes event by its ID
*/
export async function deleteEvent(eventIds: EntryId[]) {
export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate, changeList } = await scopedMutation({ eventIds });
const { didMutate } = await scopedMutation({ eventIds });
if (!didMutate) {
return;
@@ -126,7 +125,7 @@ export async function deleteEvent(eventIds: EntryId[]) {
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: changeList, external: true });
notifyChanges({ timer: eventIds, external: true });
}
/**
@@ -187,29 +186,23 @@ export async function batchEditEvents(ids: string[], data: Partial<OntimeEvent>)
/**
* reorders a given entry
* @param {string} eventId - ID of event from, for sanity check
* @param {number} from - index of event from
* @param {number} to - index of event to
*/
export async function reorderEntry(
entryId: EntryId,
destinationId: EntryId,
order: 'before' | 'after' | 'insert',
): Promise<Rundown> {
export async function reorderEntry(eventId: EntryId, from: number, to: number) {
const scopedMutation = cache.mutateCache(cache.reorder);
const { changeList, newRundown } = await scopedMutation({ entryId, destinationId, order });
const reorderedItem = await scopedMutation({ eventId, from, to });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: changeList, external: true });
notifyChanges({ timer: true, external: true });
return newRundown;
return reorderedItem;
}
/**
* Applies a delay into the rundown effectively changing the schedule
* The applied delay is deleted
* @param delayId
*/
export async function applyDelay(delayId: EntryId) {
const scopedMutation = cache.mutateCache(cache.applyDelay);
await scopedMutation({ delayId });
@@ -221,59 +214,6 @@ export async function applyDelay(delayId: EntryId) {
notifyChanges({ timer: true, external: true });
}
/**
* Clones an entry, ensuring that all dependencies are preserved
*/
export async function cloneEntry(entryId: EntryId) {
const scopedMutation = cache.mutateCache(cache.clone);
const { newRundown, newEvent } = await scopedMutation({ entryId });
// notify runtime that rundown has changed
updateRuntimeOnChange();
if (isOntimeBlock(newEvent)) {
notifyChanges({ timer: newEvent.events, external: true });
} else if (isOntimeEvent(newEvent)) {
notifyChanges({ timer: [newEvent.id], external: true });
} else if (isOntimeDelay(newEvent)) {
notifyChanges({ external: true });
}
return newRundown;
}
/**
* Deletes a block from the rundown and moves all its children to the top level
*/
export async function ungroupEntries(blockId: EntryId) {
const scopedMutation = cache.mutateCache(cache.ungroup);
const { newRundown } = await scopedMutation({ blockId });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// we dont need to modify the timer since the grouping does not affect the runtime
notifyChanges({ external: true });
return newRundown;
}
/**
* Groups a list of entries into a block
*/
export async function groupEntries(entryIds: EntryId[]) {
const scopedMutation = cache.mutateCache(cache.groupEntries);
const { newRundown } = await scopedMutation({ entryIds });
// notify runtime that rundown has changed
updateRuntimeOnChange();
// we dont need to modify the timer since the grouping does not affect the runtime
notifyChanges({ external: true });
return newRundown;
}
/**
* swaps two events
* @param {string} from - id of event from
@@ -335,7 +275,7 @@ function notifyChanges(options: NotifyChangesOptions) {
if (options.external) {
// advice socket subscribers of change
const payload = {
target: RefetchTargets.Rundown,
target: 'RUNDOWN',
changes: Array.isArray(options.timer) ? options.timer : undefined,
reload: options.reload,
revision: cache.getMetadata().revision,
@@ -1,4 +1,4 @@
import { CustomFields, OntimeBlock, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { CustomFields, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, dayInMs } from 'ontime-utils';
import { demoDb } from '../../../models/demoProject.js';
@@ -15,12 +15,9 @@ import {
editCustomField,
removeCustomField,
customFieldChangelog,
ungroup,
groupEntries,
clone,
} from '../rundownCache.js';
import { makeOntimeBlock, makeOntimeDelay, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
import type { ProcessedRundownMetadata } from '../rundownCache.utils.js';
import { ProcessedRundownMetadata } from '../rundownCache.utils.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
@@ -550,6 +547,7 @@ describe('generate() v4', () => {
endTime: 400,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'100': { type: SupportedEntry.Event, parent: '1' },
'200': { type: SupportedEntry.Event, parent: '1' },
@@ -590,6 +588,7 @@ describe('generate() v4', () => {
endTime: 400,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'101': { parent: '1', gap: 90, linkStart: false },
'102': { parent: '1' },
@@ -601,6 +600,7 @@ describe('generate() v4', () => {
endTime: 800,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'201': { id: '201', timeStart: 500, timeEnd: 600, duration: 100, gap: 100, linkStart: false },
'202': { id: '202', timeStart: 600, timeEnd: 700, duration: 100 },
@@ -612,6 +612,7 @@ describe('generate() v4', () => {
endTime: 1200,
duration: 300,
isFirstLinked: false,
numEvents: 3,
},
'301': { id: '301', timeStart: 900, timeEnd: 1000, duration: 100, gap: 100, linkStart: false },
'302': { id: '302', timeStart: 1000, timeEnd: 1100, duration: 100 },
@@ -622,60 +623,13 @@ describe('generate() v4', () => {
});
describe('add() mutation', () => {
test('adds an event an empty rundown', () => {
test('adds an event to the rundown', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({});
const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown });
const { newRundown } = add({ atIndex: 0, entry: mockEvent, parent: null, rundown });
expect(newRundown.order.length).toBe(1);
expect(newRundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top if no afterId is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1'],
order: ['1'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: '1' }),
},
});
const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: null, rundown });
expect(newRundown.order).toStrictEqual(['mock', '1']);
expect(newRundown.flatOrder).toStrictEqual(['mock', '1']);
expect(newRundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the top of the block if no after is given', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeBlock({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
const { newRundown } = add({ afterId: undefined, entry: mockEvent, parent: '1', rundown });
expect(newRundown.order).toStrictEqual(['1']);
expect(newRundown.flatOrder).toStrictEqual(['1', 'mock', '1a']);
expect(newRundown.entries['mock']).toMatchObject(mockEvent);
});
test('adds an event at the a given location inside a block', () => {
const mockEvent = makeOntimeEvent({ id: 'mock', cue: 'mock' });
const rundown = makeRundown({
flatOrder: ['1', '1a'],
order: ['1'],
entries: {
'1': makeOntimeBlock({ id: '1' }),
'1a': makeOntimeEvent({ id: '1a', parent: '1' }),
},
});
const { newRundown } = add({ afterId: '1a', entry: mockEvent, parent: '1', rundown });
expect(newRundown.order).toStrictEqual(['1']);
expect(newRundown.flatOrder).toStrictEqual(['1', '1a', 'mock']);
expect(newRundown.entries['mock']).toMatchObject(mockEvent);
});
});
describe('remove() mutation', () => {
@@ -709,28 +663,6 @@ describe('remove() mutation', () => {
expect(newRundown.order.length).toBe(3);
expect(newRundown.entries[newRundown.order[0]].id).toBe('4');
});
test('deletes a nested event', () => {
const rundown = makeRundown({
order: ['1'],
flatOrder: ['1', '11', '12', '13'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11', '12', '13'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'12': makeOntimeDelay({ id: '12', parent: '1' }),
'13': makeOntimeEvent({ id: '13', parent: '1' }),
},
});
const { newRundown } = remove({ eventIds: ['12'], rundown });
expect(newRundown.order).toStrictEqual(['1']);
expect(newRundown.entries).toMatchObject({
'1': { id: '1' },
'11': { id: '11' },
'13': { id: '13' },
});
expect((newRundown.entries['1'] as OntimeBlock).events).toStrictEqual(['11', '13']);
});
});
describe('edit() mutation', () => {
@@ -783,396 +715,29 @@ describe('batchEdit() mutation', () => {
});
describe('reorder() mutation', () => {
it('moves an event into a block', () => {
it('should correctly reorder two events', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
flatOrder: ['1', '2', '3'],
entries: {
'1': makeOntimeBlock({ id: '1', events: [] }),
'2': makeOntimeEvent({ id: '2', parent: null }),
'3': makeOntimeEvent({ id: '3', parent: null }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '3',
destinationId: '1',
order: 'insert',
});
expect(newRundown.order).toStrictEqual(['1', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '3', '2']);
// expect(changeList).toStrictEqual(['1', '3', '2']);
expect(rundown.entries['1']).toMatchObject({
events: ['3'],
});
expect(rundown.entries['3']).toMatchObject({
parent: '1',
});
});
it('adds an event into a block', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '11', '2'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeEvent({ id: '2', parent: null }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '2',
destinationId: '11',
order: 'before',
});
expect(newRundown.order).toStrictEqual(['1']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11']);
// expect(changeList).toStrictEqual(['1', '2', '11']);
expect(rundown.entries['1']).toMatchObject({
events: ['2', '11'],
});
expect(rundown.entries['2']).toMatchObject({
parent: '1',
});
});
it('moves an event after another', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
flatOrder: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1' }),
'2': makeOntimeEvent({ id: '2', cue: 'data2' }),
'3': makeOntimeEvent({ id: '3', cue: 'data3' }),
'1': makeOntimeEvent({ id: '1', cue: 'data1', revision: 0 }),
'2': makeOntimeEvent({ id: '2', cue: 'data2', revision: 0 }),
'3': makeOntimeEvent({ id: '3', cue: 'data3', revision: 0 }),
},
});
// move first event to the end
const { newRundown } = reorder({
rundown: rundown,
entryId: '1',
destinationId: '2',
order: 'after',
eventId: rundown.order[0],
from: 0,
to: rundown.order.length - 1,
});
expect(newRundown.order).toStrictEqual(['2', '1', '3']);
// expect(newRundown.flatOrder).toStrictEqual(['2', '3', '1']);
// expect(changeList).toStrictEqual(['2', '3', '1']);
});
it('moves an event before another', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
flatOrder: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1' }),
'2': makeOntimeEvent({ id: '2', cue: 'data2' }),
'3': makeOntimeEvent({ id: '3', cue: 'data3' }),
},
});
// move last event to the beginning
const { newRundown } = reorder({
rundown: rundown,
entryId: '3',
destinationId: '1',
order: 'before',
});
expect(newRundown.order).toStrictEqual(['3', '1', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['3', '1', '2']);
// expect(changeList).toStrictEqual(['3', '1', '2']);
});
it('moves an event out of a block', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '11', '2'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeEvent({ id: '2', parent: null }),
},
});
const { newRundown, changeList } = reorder({
rundown: rundown,
entryId: '11',
destinationId: '2',
order: 'before',
});
expect(newRundown.order).toStrictEqual(['1', '11', '2']);
expect(newRundown.flatOrder).toStrictEqual(['1', '11', '2']);
expect(changeList).toStrictEqual(['1', '11', '2']);
expect(rundown.entries['1']).toMatchObject({
events: [],
});
expect(rundown.entries['2']).toMatchObject({
parent: null,
});
});
it('moves an event between blocks', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '11',
destinationId: '22',
order: 'before',
});
expect(newRundown.order).toStrictEqual(['1', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']);
// expect(changeList).toStrictEqual(['1', '2', '11', '22']);
expect(rundown.entries['1']).toMatchObject({
events: [],
});
expect(rundown.entries['2']).toMatchObject({
events: ['11', '22'],
});
expect(rundown.entries['11']).toMatchObject({
parent: '2',
});
});
it('moves an event into an empty block', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '2', '22'],
entries: {
'1': makeOntimeBlock({ id: '1', events: [] }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '22',
destinationId: '1',
order: 'insert',
});
expect(newRundown.order).toStrictEqual(['1', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']);
// expect(changeList).toStrictEqual(['1', '2', '11', '22']);
expect(rundown.entries['1']).toMatchObject({
events: ['22'],
});
expect(rundown.entries['2']).toMatchObject({
events: [],
});
expect(rundown.entries['22']).toMatchObject({
parent: '1',
});
});
it('moves an event out of a block (up)', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '22',
destinationId: '2',
order: 'before',
});
expect(newRundown.order).toStrictEqual(['1', '22', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']);
// expect(changeList).toStrictEqual(['1', '2', '11', '22']);
expect(rundown.entries['1']).toMatchObject({
events: ['11'],
});
expect(rundown.entries['11']).toMatchObject({
parent: '1',
});
expect(rundown.entries['2']).toMatchObject({
events: [],
});
expect(rundown.entries['22']).toMatchObject({
parent: null,
});
});
it('moves an event out of a block (down)', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '11', '2', '22'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['11'] }),
'11': makeOntimeEvent({ id: '11', parent: '1' }),
'2': makeOntimeBlock({ id: '2', events: ['22'] }),
'22': makeOntimeEvent({ id: '22', parent: '2' }),
},
});
const { newRundown } = reorder({
rundown: rundown,
entryId: '11',
destinationId: '1',
order: 'after',
});
expect(newRundown.order).toStrictEqual(['1', '11', '2']);
// expect(newRundown.flatOrder).toStrictEqual(['1', '2', '11', '22']);
// expect(changeList).toStrictEqual(['1', '2', '11', '22']);
expect(rundown.entries['1']).toMatchObject({
events: [],
});
expect(rundown.entries['11']).toMatchObject({
parent: null,
});
expect(rundown.entries['2']).toMatchObject({
events: ['22'],
});
expect(rundown.entries['22']).toMatchObject({
parent: '2',
});
});
});
describe('clone() mutation', () => {
it('clones an event and adds it to the rundown', () => {
const rundown = makeRundown({
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }),
},
});
const { newRundown, newEvent } = clone({ rundown, entryId: '1' });
expect(newRundown.order).toStrictEqual(['1', newEvent!.id]);
expect(newRundown.flatOrder).toStrictEqual(['1', newEvent!.id]);
});
it('clones an event inside a block and adds it to the rundown', () => {
const rundown = makeRundown({
order: ['1'],
flatOrder: ['1', '1a'],
entries: {
'1': makeOntimeBlock({ id: '1', events: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
},
});
const { newRundown, newEvent } = clone({ rundown, entryId: '1a' });
expect(newRundown.order).toStrictEqual(['1']);
expect(newRundown.flatOrder).toStrictEqual(['1', '1a', newEvent!.id]);
expect(newRundown.entries['1']).toMatchObject({ events: ['1a', newEvent!.id] });
expect(newRundown.entries[newEvent!.id]).toMatchObject({
type: SupportedEntry.Event,
parent: '1',
cue: 'nested',
});
});
it('clones a block and its nested elements', () => {
const rundown = makeRundown({
order: ['1'],
flatOrder: ['1', '1a'],
entries: {
'1': makeOntimeBlock({ id: '1', title: 'top', events: ['1a'] }),
'1a': makeOntimeEvent({ id: '1a', cue: 'nested', parent: '1' }),
},
});
const { newRundown, newEvent } = clone({ rundown, entryId: '1' });
expect(newRundown.order).toStrictEqual(['1', newEvent!.id]);
expect(newRundown.flatOrder).toStrictEqual(['1', '1a', expect.any(String), expect.any(String)]);
expect(newRundown.entries[newEvent!.id]).toMatchObject({
type: SupportedEntry.Block,
events: [expect.any(String)],
});
});
});
describe('ungroup() mutation', () => {
it('should correctly dissolve a block into its events', () => {
const rundown = makeRundown({
order: ['1', '2'],
flatOrder: ['1', '2', '21', '22'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'data1', parent: null }),
'2': makeOntimeBlock({ id: '2', events: ['21', '22'] }),
'21': makeOntimeEvent({ id: '21', cue: 'data21', parent: '2' }),
'22': makeOntimeEvent({ id: '22', cue: 'data22', parent: '2' }),
},
});
const { newRundown } = ungroup({
rundown,
blockId: '2',
});
expect(newRundown.order).toStrictEqual(['1', '21', '22']);
expect(newRundown.flatOrder).toStrictEqual(['1', '21', '22']);
expect(newRundown.entries['2']).toBeUndefined();
expect(newRundown.order).toStrictEqual(['2', '3', '1']);
expect(newRundown.entries).toMatchObject({
'1': { id: '1', type: SupportedEntry.Event, cue: 'data1', parent: null },
'21': { id: '21', type: SupportedEntry.Event, cue: 'data21', parent: null },
'22': { id: '22', type: SupportedEntry.Event, cue: 'data22', parent: null },
});
});
});
describe('groupEntries() mutation', () => {
it('groups a list of existing events into a new block', () => {
const rundown = makeRundown({
order: ['1', '2', '3'],
flatOrder: ['1', '2', '3'],
entries: {
'1': makeOntimeEvent({ id: '1', parent: null }),
'2': makeOntimeEvent({ id: '2', parent: null }),
'3': makeOntimeEvent({ id: '3', parent: null }),
},
});
const { newRundown } = groupEntries({
rundown,
entryIds: ['1', '2'],
});
const blockId = newRundown.order[0];
expect(blockId).toStrictEqual(expect.any(String));
expect(newRundown.order).toStrictEqual([expect.any(String), '3']);
expect(newRundown.flatOrder).toStrictEqual([expect.any(String), '1', '2', '3']);
expect(newRundown.entries).toMatchObject({
[blockId]: {
type: SupportedEntry.Block,
events: ['1', '2'],
},
'1': { id: '1', type: SupportedEntry.Event, parent: blockId },
'2': { id: '2', type: SupportedEntry.Event, parent: blockId },
'3': { id: '3', type: SupportedEntry.Event, parent: null },
'2': { id: '2', cue: 'data2', revision: 1 },
'3': { id: '3', cue: 'data3', revision: 1 },
'1': { id: '1', cue: 'data1', revision: 1 },
});
});
});
@@ -1,26 +0,0 @@
import { getPreviousId } from '../rundownUtils.js';
// Mock cache module
vi.mock('../rundownCache.js', () => ({
getEventOrder: () => ({
flatOrder: ['a', 'b', 'c', 'd'],
}),
}));
describe('getPreviousId', () => {
it('returns afterId if provided', () => {
expect(getPreviousId('b')).toBe('b');
});
it('returns the previous id before beforeId if provided', () => {
expect(getPreviousId(undefined, 'c')).toBe('b');
});
it('returns undefined if neither afterId nor beforeId is provided', () => {
expect(getPreviousId()).toBeUndefined();
});
it('returns undefined if beforeId is not found', () => {
expect(getPreviousId(undefined, 'z')).toBeUndefined();
});
});
@@ -11,23 +11,15 @@ import {
OntimeEntry,
Rundown,
RundownEntries,
OntimeDelay,
} from 'ontime-types';
import { generateId, insertAtIndex, swapEventData, customFieldLabelToKey, mergeAtIndex } from 'ontime-utils';
import { generateId, insertAtIndex, reorderArray, swapEventData, customFieldLabelToKey } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createBlock, createPatch } from '../../api-data/rundown/rundown.utils.js';
import { createPatch } from '../../api-data/rundown/rundown.utils.js';
import type { RundownMetadata } from './rundown.types.js';
import { apply } from './delayUtils.js';
import {
cloneBlock,
cloneEntry,
hasChanges,
isDataStale,
makeRundownMetadata,
type ProcessedRundownMetadata,
} from './rundownCache.utils.js';
import { hasChanges, isDataStale, makeRundownMetadata, type ProcessedRundownMetadata } from './rundownCache.utils.js';
let currentRundownId: EntryId = '';
let currentRundown: Rundown = {
@@ -122,7 +114,6 @@ export function generate(
let blockStartTime = null;
let blockEndTime = null;
let isFirstLinked = false;
const blockEvents: EntryId[] = [];
// check if the block contains events
for (let i = 0; i < processedEntry.events.length; i++) {
@@ -132,8 +123,6 @@ export function generate(
if (!nestedEntry) {
continue;
}
blockEvents.push(nestedEntry.id);
const { processedData: processedNestedData, processedEntry: processedNestedEntry } = process(
nestedEntry,
processedEntry.id,
@@ -161,7 +150,7 @@ export function generate(
processedEntry.startTime = blockStartTime;
processedEntry.endTime = blockEndTime;
processedEntry.isFirstLinked = isFirstLinked;
processedEntry.events = blockEvents;
processedEntry.numEvents = processedEntry.events.length;
}
}
@@ -270,7 +259,6 @@ export function getMetadata(): Readonly<RundownMetadata & { revision: number }>
export type RundownOrder = {
order: EntryId[];
flatOrder: EntryId[];
timedEventsOrder: EntryId[];
playableEventsOrder: EntryId[];
};
@@ -284,7 +272,6 @@ export function getEventOrder(): Readonly<RundownOrder> {
}
return {
order: currentRundown.order,
flatOrder: currentRundown.flatOrder,
timedEventsOrder: rundownMetadata.timedEventOrder,
playableEventsOrder: rundownMetadata.playableEventOrder,
};
@@ -295,7 +282,6 @@ type MutationParams<T> = T & CommonParams;
type MutatingReturn = {
newRundown: Rundown;
newEvent?: OntimeEntry;
changeList?: EntryId[];
didMutate: boolean;
};
type MutatingFn<T extends object> = (params: MutationParams<T>) => MutatingReturn;
@@ -308,11 +294,11 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
function scopedMutation(params: T) {
// we work on a copy of the rundown
const rundownCopy = structuredClone(currentRundown);
const { newEvent, newRundown, changeList, didMutate } = mutation({ ...params, rundown: rundownCopy });
const { newEvent, newRundown, didMutate } = mutation({ ...params, rundown: rundownCopy });
// early return without calling side effects
if (!didMutate) {
return { newEvent, newRundown, changeList, didMutate };
return { newEvent, newRundown, didMutate };
}
newRundown.revision += 1;
@@ -334,96 +320,55 @@ export function mutateCache<T extends object>(mutation: MutatingFn<T>) {
return scopedMutation;
}
type AddArgs = MutationParams<{ afterId?: string; parent: EntryId | null; entry: OntimeEntry }>;
type AddArgs = MutationParams<{ atIndex: number; parent: EntryId | null; entry: OntimeEntry }>;
/**
* Add entry to rundown, handles the following cases:
* - 1. add entry in block, after a given entry
* - 2. add entry in block, at the beginning
* - 3. add entry to the rundown, after a given entry
* - 4. add entry to the rundown, at the beginning
* Add entry to rundown
*/
export function add({ rundown, afterId, parent, entry }: AddArgs): Required<MutatingReturn> {
export function add({ rundown, atIndex, parent, entry }: AddArgs): Required<MutatingReturn> {
const newEntry: OntimeEntry = { ...entry };
rundown.entries[newEntry.id] = newEntry;
if (parent) {
const parentBlock = rundown.entries[parent] as OntimeBlock;
if (afterId) {
const atEventsIndex = parentBlock.events.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
parentBlock.events = insertAtIndex(atEventsIndex, entry.id, parentBlock.events);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
parentBlock.events = insertAtIndex(0, entry.id, parentBlock.events);
const atFlatIndex = rundown.flatOrder.indexOf(parent) + 1;
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
}
parentBlock.events = insertAtIndex(atIndex, newEntry.id, parentBlock.events);
} else {
if (afterId) {
const atOrderIndex = rundown.order.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
rundown.order = insertAtIndex(atOrderIndex, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(atFlatIndex, entry.id, rundown.flatOrder);
} else {
rundown.order = insertAtIndex(0, entry.id, rundown.order);
rundown.flatOrder = insertAtIndex(0, entry.id, rundown.flatOrder);
}
rundown.order = insertAtIndex(atIndex, newEntry.id, rundown.order);
}
// either way, we insert the entry into the rundown
rundown.entries[entry.id] = entry;
setIsStale();
return { newRundown: rundown, changeList: [], newEvent: entry, didMutate: true };
return { newRundown: rundown, newEvent: newEntry, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventIds: EntryId[] }>;
/**
* Remove entries in a rundown
* It handles element relationships specifically when dealing with nested items
* - when removing a nested item, remove the reference from the parent block
* - when removing a block, remove all nested items
*/
export function remove({ rundown, eventIds }: RemoveArgs): MutatingReturn {
/**
* changelist will hold a list of entries that need to be removed
* it will then be returned to the caller as a list of actually deleted entries
*/
const changeList: EntryId[] = [];
let didMutate = false;
for (let i = 0; i < eventIds.length; i++) {
const entry = rundown.entries[eventIds[i]];
// add the top level entry to the changeList
changeList.push(entry.id);
if (isOntimeBlock(entry)) {
// for ontime blocks, we need to iterate through the children and delete them
changeList.concat([...entry.events]);
} else if (entry.parent) {
// at this point, we are handling entries inside a block, so we need to remove the references
if (isOntimeEvent(entry) && entry.parent) {
const parentBlock = rundown.entries[entry.parent] as OntimeBlock;
const parentEvents = parentBlock.events.filter((id) => id !== eventIds[i]);
// we call a mutation to the parent event to
// - remove this entry from the events
// - reduce the children count
edit({
rundown,
eventId: entry.parent,
patch: {
events: parentEvents,
events: parentBlock.events.filter((id) => id !== eventIds[i]),
numEvents: parentBlock.events.length - 1,
},
});
parentBlock.events = parentBlock.events.filter((id) => id !== entry.id);
} else {
rundown.order = rundown.order.filter((id) => id !== eventIds[i]);
}
didMutate = true;
delete rundown.entries[eventIds[i]];
}
// delete all entries in the changeList
for (let i = 0; i < changeList.length; i++) {
const entryId = changeList[i];
rundown.order = rundown.order.filter((id) => id !== entryId);
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
delete rundown.entries[entryId];
}
const didMutate = changeList.length > 0;
if (didMutate) setIsStale();
return { newRundown: rundown, didMutate, changeList };
return { newRundown: rundown, didMutate };
}
/**
@@ -482,7 +427,7 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingRe
// if nothing changed, nothing to do
if (!hasChanges(entry, patch)) {
return { newRundown: rundown, changeList: [eventId], newEvent: entry, didMutate: false };
return { newRundown: rundown, newEvent: entry, didMutate: false };
}
const newEvent = makeEvent(entry, patch);
@@ -497,7 +442,7 @@ export function edit({ rundown, eventId, patch }: EditArgs): Required<MutatingRe
rundown.entries[newEvent.id] = newEvent;
}
return { newRundown: rundown, changeList: [newEvent.id], newEvent, didMutate: true };
return { newRundown: rundown, newEvent, didMutate: true };
}
type BatchEditArgs = MutationParams<{ eventIds: EntryId[]; patch: Partial<OntimeEntry> }>;
@@ -511,62 +456,29 @@ export function batchEdit({ rundown, eventIds, patch }: BatchEditArgs): Mutating
return { newRundown: rundown, didMutate: true };
}
type ReorderArgs = MutationParams<{
entryId: EntryId;
destinationId: EntryId;
order: 'before' | 'after' | 'insert';
}>;
type ReorderArgs = MutationParams<{ eventId: EntryId; from: number; to: number }>;
/**
* Moves an event to a new position in the rundown
* Handles moving across root orders (a block order and top level order)
* @throws if entryId or destinationId not found
* @throws if trying to insert an event into a block inside another block
* Reorder two entries
*/
export function reorder({ rundown, entryId, destinationId, order }: ReorderArgs): Required<MutatingReturn> {
const eventFrom = rundown.entries[entryId];
const eventTo = rundown.entries[destinationId];
if (!eventFrom || !eventTo) {
export function reorder({ rundown, eventId, from, to }: ReorderArgs): Required<MutatingReturn> {
const eventFrom = rundown.entries[eventId];
if (!eventFrom) {
throw new Error('Event not found');
}
const fromParent: EntryId | null = (eventFrom as { parent?: EntryId })?.parent ?? null;
const toParent = (() => {
if (isOntimeBlock(eventTo)) {
if (order === 'insert') {
return eventTo.id;
}
return null;
}
return eventTo.parent ?? null;
})();
rundown.order = reorderArray(rundown.order, from, to);
if (!isOntimeBlock(eventFrom)) {
eventFrom.parent = toParent;
// increment revision of all events in between
for (let i = from; i <= to; i++) {
const eventId = rundown.order[i];
const entry = rundown.entries[eventId];
if (isOntimeEvent(entry) || isOntimeBlock(entry)) {
entry.revision += 1;
}
}
const sourceArray = fromParent === null ? rundown.order : (rundown.entries[fromParent] as OntimeBlock).events;
const destinationArray = toParent === null ? rundown.order : (rundown.entries[toParent] as OntimeBlock).events;
const fromIndex = sourceArray.indexOf(entryId);
const toIndex = (() => {
const baseIndex = destinationArray.indexOf(destinationId);
if (order === 'before') return baseIndex;
// only add one if we are moving down
if (order === 'after') return baseIndex + (fromIndex < baseIndex ? 0 : 1);
// for insert we add in the end of the array
return destinationArray.length;
})();
// Remove from source array
sourceArray.splice(fromIndex, 1);
// Insert into destination array
destinationArray.splice(toIndex, 0, entryId);
// changelist is derived from the flat order
const changeList = rundown.flatOrder.slice(Math.min(fromIndex, toIndex), rundown.flatOrder.length);
return { newRundown: rundown, changeList, newEvent: eventFrom, didMutate: true };
setIsStale();
return { newRundown: rundown, newEvent: eventFrom, didMutate: true };
}
type ApplyDelayArgs = MutationParams<{ delayId: EntryId }>;
@@ -580,140 +492,6 @@ export function applyDelay({ rundown, delayId }: ApplyDelayArgs): MutatingReturn
return { newRundown: rundown, didMutate: true };
}
type CloneEntryArgs = MutationParams<{ entryId: EntryId }>;
/**
* Apply a delay
* Mutates the given rundown
*/
export function clone({ rundown, entryId }: CloneEntryArgs): MutatingReturn {
const entry = rundown.entries[entryId];
if (!entry) {
throw new Error('Entry not found');
}
if (isOntimeBlock(entry)) {
const newBlock = cloneBlock(entry, getUniqueId());
const nestedIds: EntryId[] = [];
for (let i = 0; i < entry.events.length; i++) {
const nestedEntryId = entry.events[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new block
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId());
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newBlock.id;
nestedIds.push(newNestedEntry.id);
// we immediately insert the nested entries into the rundown
rundown.entries[newNestedEntry.id] = newNestedEntry;
}
// indexes + 1 since we are inserting after the cloned block
const atIndex = rundown.order.indexOf(entryId) + 1;
// we need to find the index of the last entry
const lastNestedIdInOriginal = entry.events.at(-1) ?? '0';
const flatIndex = rundown.flatOrder.indexOf(lastNestedIdInOriginal) + 1;
newBlock.events = nestedIds;
newBlock.title = `${entry.title || 'Untitled'} (copy)`;
rundown.entries[newBlock.id] = newBlock;
rundown.order = insertAtIndex(atIndex, newBlock.id, rundown.order);
rundown.flatOrder = mergeAtIndex(flatIndex, [newBlock.id, ...nestedIds], rundown.flatOrder);
return { newRundown: rundown, didMutate: true, newEvent: newBlock };
} else {
return add({ rundown, afterId: entryId, parent: entry.parent, entry: cloneEntry(entry, getUniqueId()) });
}
}
type UngroupArgs = MutationParams<{ blockId: EntryId }>;
/**
* Deletes a block and moves all its children to the top level order
* Mutates the given rundown
* @throws if block ID not found
*/
export function ungroup({ rundown, blockId }: UngroupArgs): MutatingReturn {
const block = rundown.entries[blockId];
if (!isOntimeBlock(block)) {
throw new Error('Block with ID not found');
}
// get the events from the block and merge them into the order where the block was
const nestedEvents = block.events;
const blockIndex = rundown.order.indexOf(blockId);
rundown.order.splice(blockIndex, 1, ...nestedEvents);
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== blockId);
// delete block from entries and remove its reference from the child events
delete rundown.entries[blockId];
for (let i = 0; i < nestedEvents.length; i++) {
const eventId = nestedEvents[i];
const entry = rundown.entries[eventId];
if (!entry) {
throw new Error('Entry not found');
}
(entry as OntimeEvent | OntimeDelay).parent = null;
}
return { newRundown: rundown, didMutate: true };
}
type GroupArgs = MutationParams<{ entryIds: EntryId[] }>;
/**
* Groups a list of entries into a block
* It ensures that the entries get reassigned parent and the block gets a list of events
* The block will be created at the index of the first event in the order, not at the lowest index
* Mutates the given rundown
* @throws if any of the entries is a block
* @throws if any of the entries is not found
*/
export function groupEntries({ rundown, entryIds }: GroupArgs): MutatingReturn {
const block = createBlock({ id: getUniqueId() });
const nestedEvents: EntryId[] = [];
let firstIndex = -1;
for (let i = 0; i < entryIds.length; i++) {
const entryId = entryIds[i];
const entry = rundown.entries[entryId];
if (!entry) {
throw new Error('Entry not found');
}
if (isOntimeBlock(entry)) {
throw new Error('Cannot group a block');
}
if (entry.parent !== null) {
throw new Error('Entry already has a parent');
}
// the block will be created at the first selected event position
// note that this is not the lowest index
if (firstIndex === -1) {
firstIndex = rundown.flatOrder.indexOf(entryId);
}
nestedEvents.push(entryId);
entry.parent = block.id;
rundown.flatOrder = rundown.flatOrder.filter((id) => id !== entryId);
rundown.order = rundown.order.filter((id) => id !== entryId);
}
block.events = nestedEvents;
const insertIndex = Math.max(0, firstIndex);
// we have filtered the items from the order
// we will insert them now, with only the block at top level ...
rundown.order = insertAtIndex(insertIndex, block.id, rundown.order);
/// ... and the nested elements after the block in the flat order
rundown.flatOrder = mergeAtIndex(insertIndex, [block.id, ...nestedEvents], rundown.flatOrder);
rundown.entries[block.id] = block;
return { newRundown: rundown, didMutate: true };
}
type SwapArgs = MutationParams<{ fromId: EntryId; toId: EntryId }>;
/**
* Swap two entries
@@ -10,8 +10,6 @@ import {
isOntimeDelay,
PlayableEvent,
RundownEntries,
OntimeDelay,
OntimeBlock,
} from 'ontime-types';
import { dayInMs, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
@@ -277,7 +275,6 @@ function processEntry<T extends OntimeEntry>(
} else if (isOntimeDelay(currentEntry)) {
// !!! this must happen after handling the links
processedData.totalDelay += currentEntry.duration;
currentEntry.parent = childOfBlock;
}
if (!childOfBlock) {
@@ -288,37 +285,3 @@ function processEntry<T extends OntimeEntry>(
return { processedData, processedEntry: currentEntry };
}
export function cloneEvent(entry: OntimeEvent, newId: EntryId): OntimeEvent {
const newEntry = structuredClone(entry);
newEntry.id = newId;
newEntry.revision = 0;
return newEntry;
}
export function cloneDelay(entry: OntimeDelay, newId: EntryId): OntimeDelay {
const newEntry = structuredClone(entry);
newEntry.id = newId;
return newEntry;
}
export function cloneBlock(entry: OntimeBlock, newId: EntryId): OntimeBlock {
const newEntry = structuredClone(entry);
newEntry.id = newId;
// in blocks, we need to remove the events references
newEntry.events = [];
newEntry.revision = 0;
return newEntry;
}
export function cloneEntry<T extends OntimeEntry>(entry: T, newId: EntryId): T {
if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId) as T;
} else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId) as T;
} else if (entry.type === 'block') {
return cloneBlock(entry as OntimeBlock, newId) as T;
}
throw new Error(`Unsupported entry type for cloning: ${entry}`);
}
@@ -6,6 +6,7 @@ import {
EntryId,
RundownEntries,
ProjectRundowns,
OntimeBlock,
} from 'ontime-types';
import * as cache from './rundownCache.js';
@@ -174,21 +175,37 @@ export function getRundownOrThrow(rundowns: ProjectRundowns, rundownId: string):
return rundowns[rundownId];
}
/**
* Receives an insertion order and returns the reference to an event ID
* after which we will insert the new event
*/
export function getPreviousId(afterId?: EntryId, beforeId?: EntryId): EntryId | undefined {
export function getInsertionPosition(
parentId: EntryId | null,
afterId?: EntryId,
beforeId?: EntryId,
): { atIndex: number; afterId: EntryId | undefined } {
if (afterId) {
return afterId;
const order = selectOrderList(parentId);
return {
atIndex: order.findIndex((id) => id === afterId) + 1,
afterId,
};
}
if (beforeId) {
const flatOrder = cache.getEventOrder().flatOrder;
const atIndex = flatOrder.findIndex((id) => id === beforeId);
if (atIndex < 1) return undefined;
return flatOrder[atIndex - 1];
const order = selectOrderList(parentId);
const atIndex = order.findIndex((id) => id === beforeId);
return {
atIndex,
afterId: order[atIndex - 1] ?? null,
};
}
return;
return {
atIndex: 0,
afterId: undefined,
};
function selectOrderList(parentId: EntryId | null) {
if (parentId) {
return (getEntryWithId(parentId) as OntimeBlock).events;
}
return cache.getEventOrder().order;
}
}
@@ -1,6 +1,5 @@
import {
EndAction,
EntryId,
isOntimeEvent,
isPlayableEvent,
LogOrigin,
@@ -239,7 +238,7 @@ class RuntimeService {
* Called when the underlying data has changed,
* we check if the change affects the runtime
*/
public notifyOfChangedEvents(affectedIds?: EntryId[]) {
public notifyOfChangedEvents(affectedIds?: string[]) {
const state = runtimeState.getState();
const hasLoadedElements = state.eventNow !== null || state.eventNext !== null;
if (!hasLoadedElements) {
@@ -717,6 +716,11 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
// combine all big changes
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback || offsetModeChanged;
// we would like the wall clock to tick on a regular rate
const normalClockUpdate =
getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock) ||
getForceUpdate(RuntimeService.previousClockUpdate, state.clock);
/**
* Timer should be updated if
* - big changes
@@ -733,6 +737,7 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
/**
* Runtime should be updated if
* - clock tick
* - big changes
* - the timer is updating so runtime also updates to keep them in sync ???
* - notification rate has been exceeded
@@ -740,7 +745,10 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
* Then check if there is actually a change in the data
*/
const shouldRuntimeUpdate =
(hasImmediateChanges || shouldUpdateTimer || getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) &&
(normalClockUpdate ||
hasImmediateChanges ||
shouldUpdateTimer ||
getForceUpdate(RuntimeService.previousRuntimeUpdate, state.clock)) &&
!deepEqual(RuntimeService.previousState?.runtime, state.runtime);
/**
@@ -751,13 +759,9 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
/**
* Many other values are calculated based on the clock
* so if any of them are updated we also need to send the clock
* in case nothing else is updating the clock will bw updated at the notification rate
* in case nothing else is updating the clock will be updated at the notification rate
*/
const shouldUpdateClock =
shouldUpdateTimer ||
shouldRuntimeUpdate ||
shouldBlockUpdate ||
getForceUpdate(RuntimeService.previousClockUpdate, state.clock);
const shouldUpdateClock = shouldRuntimeUpdate || shouldBlockUpdate || normalClockUpdate;
//Now we set all the updates on the eventstore and update the previous value
if (hasChangedPlayback) {
@@ -1,20 +1,16 @@
import { millisToSeconds } from 'ontime-utils';
import { MaybeNumber } from 'ontime-types';
import { MaybeNumber, TimerType } from 'ontime-types';
import { timerConfig } from '../../setup/config.js';
/**
* Checks whether we should update the clock value
* - clock has slid
* - we have rolled into a new seconds unit
* this is different from the timer update as it looks at the clock as counting up
*/
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
const shouldForceUpdate = getForceUpdate(previousUpdate, now);
if (shouldForceUpdate) {
return true;
}
const isClockSecondAhead = millisToSeconds(now) !== millisToSeconds(previousUpdate + timerConfig.triggerAhead);
return isClockSecondAhead;
const newSeconds = millisToSeconds(now, TimerType.CountUp) !== millisToSeconds(previousUpdate, TimerType.CountUp);
return newSeconds;
}
/**
@@ -22,10 +18,6 @@ export function getShouldClockUpdate(previousUpdate: number, now: number): boole
* - we have rolled into a new seconds unit
*/
export function getShouldTimerUpdate(previousValue: MaybeNumber, currentValue: MaybeNumber): boolean {
if (currentValue === null) {
return false;
}
// we avoid trigger ahead since it can cause duplicate triggers
const shouldUpdateTimer = millisToSeconds(currentValue) !== millisToSeconds(previousValue);
return shouldUpdateTimer;
}
@@ -39,6 +31,5 @@ export function getShouldTimerUpdate(previousValue: MaybeNumber, currentValue: M
export function getForceUpdate(previousUpdate: number, now: number): boolean {
const isClockBehind = now < previousUpdate;
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
const newSeconds = millisToSeconds(previousUpdate) !== millisToSeconds(now);
return isClockBehind || hasExceededRate || newSeconds;
return isClockBehind || hasExceededRate;
}
-1
View File
@@ -1,4 +1,3 @@
/**
* This CSS file allows user customisation of the UI
* We expose some CSS properties to facilitate this (see below in :root)
+2 -1
View File
@@ -180,7 +180,8 @@
"startTime": null,
"endTime": null,
"duration": 0,
"isFirstLinked": false
"isFirstLinked": false,
"numEvents": 0
},
"1c420": {
"type": "event",
+4 -2
View File
@@ -180,7 +180,8 @@
"startTime": null,
"endTime": null,
"duration": 0,
"isFirstLinked": false
"isFirstLinked": false,
"numEvents": 0
},
"1c420": {
"type": "event",
@@ -340,7 +341,8 @@
"startTime": null,
"endTime": null,
"duration": 0,
"isFirstLinked": false
"isFirstLinked": false,
"numEvents": 0
},
"503c4": {
"type": "event",
@@ -33,6 +33,7 @@ export type OntimeBlock = OntimeBaseEvent & {
endTime: MaybeNumber; // calculated at runtime
duration: number; // calculated at runtime
isFirstLinked: boolean; // calculated at runtime, whether the first event is linked
numEvents: number; // calculated at runtime
};
export type OntimeEvent = OntimeBaseEvent & {
@@ -6,8 +6,7 @@ export type RundownEntries = Record<EntryId, OntimeEntry>;
// we need to create a manual union type since keys cannot be used in type unions
export type OntimeEntryCommonKeys = keyof OntimeEvent | keyof OntimeDelay | keyof OntimeBlock;
type RundownId = string;
export type ProjectRundowns = Record<RundownId, Rundown>;
export type ProjectRundowns = Record<string, Rundown>;
export type Rundown = {
id: string;
+1 -3
View File
@@ -36,8 +36,6 @@ export {
MILLIS_PER_HOUR,
MILLIS_PER_MINUTE,
MILLIS_PER_SECOND,
millisToHours,
millisToMinutes,
millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js';
@@ -61,7 +59,7 @@ export { customFieldLabelToKey, customKeyFromLabel } from './src/customField-uti
export { deepmerge } from './src/externals/deepmerge.js';
// array utils
export { deleteAtIndex, insertAtIndex, mergeAtIndex, reorderArray } from './src/common/arrayUtils.js';
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils
export { getPropertyFromPath, isObjectEmpty } from './src/common/objectUtils.js';
+3 -36
View File
@@ -1,4 +1,4 @@
import { deleteAtIndex, insertAtIndex, mergeAtIndex, reorderArray } from './arrayUtils.js';
import { deleteAtIndex, insertAtIndex, reorderArray } from './arrayUtils.js';
describe('insertAtIndex', () => {
it('should insert an item at the beginning of the array', () => {
@@ -23,34 +23,7 @@ describe('insertAtIndex', () => {
const array = [1, 2, 3];
const result = insertAtIndex(1, 5, array);
expect(result).toEqual([1, 5, 2, 3]);
expect(array).toEqual([1, 2, 3]);
});
});
describe('mergeAtIndex', () => {
it('should insert an item at the beginning of the array', () => {
const array = ['1', '2', '3'];
const result = mergeAtIndex(0, ['a', 'b'], array);
expect(result).toEqual(['a', 'b', '1', '2', '3']);
});
it('should insert an item at the end of the array', () => {
const array = ['1', '2', '3'];
const result = mergeAtIndex(3, ['a', 'b'], array);
expect(result).toEqual(['1', '2', '3', 'a', 'b']);
});
it('should insert an item in the middle of the array', () => {
const array = ['1', '2', '3'];
const result = mergeAtIndex(2, ['a', 'b'], array);
expect(result).toEqual(['1', '2', 'a', 'b', '3']);
});
it('should return a new array and not modify the original array', () => {
const array = ['1', '2', '3'];
const result = mergeAtIndex(5, ['a', 'b'], array);
expect(result).toEqual(['1', '2', '3', 'a', 'b']);
expect(array).toEqual(['1', '2', '3']);
expect(array).toEqual([1, 2, 3]); // Original array should remain unchanged
});
});
@@ -88,18 +61,12 @@ describe('deleteAtIndex', () => {
});
describe('reorderArray', () => {
it('should reorder an item in the array (up)', () => {
it('should reorder an item in the array', () => {
const array = ['a', 'b', 'c', 'd'];
const result = reorderArray(array, 1, 3);
expect(result).toEqual(['a', 'c', 'd', 'b']);
});
it('should reorder an item in the array (down)', () => {
const array = ['a', 'b', 'c', 'd'];
const result = reorderArray(array, 3, 1);
expect(result).toEqual(['a', 'd', 'b', 'c']);
});
it('should return the original array if fromIndex and toIndex are the same', () => {
const array = ['a', 'b', 'c'];
const result = reorderArray(array, 1, 1);
-18
View File
@@ -25,24 +25,6 @@ export function insertAtIndex<T>(index: number, item: T, array: T[]): T[] {
return modifiedArray;
}
/**
* Inserts an array into another one of the same type at a given index
*/
export function mergeAtIndex<T>(index: number, newArray: T[], currentArray: T[]): T[] {
// Insert at beginning
if (index === 0) {
return [...newArray, ...currentArray];
}
// insert at end
else if (index >= currentArray.length) {
return [...currentArray, ...newArray];
}
// insert in the middle
return currentArray.toSpliced(index, 0, ...newArray);
}
/**
* Deletes array element at a given index
* @param index
@@ -1,4 +1,4 @@
import { millisToHours, millisToMinutes, millisToSeconds, secondsInMillis } from './conversionUtils';
import { millisToSeconds, secondsInMillis } from './conversionUtils';
describe('millisToSecond()', () => {
test('null values', () => {
@@ -37,82 +37,6 @@ describe('millisToSecond()', () => {
});
});
describe('millisToMinutes()', () => {
test('null values', () => {
const t = { val: null, result: 0 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('valid millis', () => {
const t = { val: 3600000, result: 60 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('negative millis', () => {
const t = { val: -3600000, result: -60 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('0', () => {
const t = { val: 0, result: 0 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('-0', () => {
const t = { val: -0, result: 0 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 1440 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
test('-86401000 (-24 hours and 1 second)', () => {
// negative numbers are rounded up
const t = { val: -86401000, result: -1441 };
expect(millisToMinutes(t.val)).toBe(t.result);
});
});
describe('millisToHours()', () => {
test('null values', () => {
const t = { val: null, result: 0 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('valid millis', () => {
const t = { val: 3600000, result: 1 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('negative millis', () => {
const t = { val: -3600000, result: -1 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('0', () => {
const t = { val: 0, result: 0 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('-0', () => {
const t = { val: -0, result: 0 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('86401000 (24 hours and 1 second)', () => {
const t = { val: 86401000, result: 24 };
expect(millisToHours(t.val)).toBe(t.result);
});
test('-86401000 (-24 hours and 1 second)', () => {
// negative numbers are rounded up
const t = { val: -86401000, result: -25 };
expect(millisToHours(t.val)).toBe(t.result);
});
});
describe('secondsInMillis()', () => {
it('return 0 if value is null', () => {
expect(secondsInMillis(null)).toBe(0);
@@ -1,4 +1,5 @@
import type { MaybeNumber } from 'ontime-types';
import { TimerType } from 'ontime-types';
export const MILLIS_PER_SECOND = 1000;
export const MILLIS_PER_MINUTE = 1000 * 60;
@@ -7,44 +8,28 @@ export const MILLIS_PER_HOUR = 1000 * 60 * 60;
export const dayInMs = 86400000;
export const maxDuration = dayInMs - MILLIS_PER_SECOND;
/**
* Utility converts milliseconds to a specific unit
* @param millis
* @param conversion
* @returns
*/
function convertMillis(millis: MaybeNumber, conversion: number): number {
if (!millis) {
return 0;
}
return Math.floor(millis / conversion);
}
/**
* Converts value in milliseconds to seconds
* @param millis
* @returns
*/
export function millisToSeconds(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_SECOND);
}
export function millisToSeconds(
millis: MaybeNumber,
direction: TimerType.CountDown | TimerType.CountUp = TimerType.CountDown,
) {
if (millis === null) return 0;
/**
* Converts value in milliseconds to minutes
* @param millis
* @returns
*/
export function millisToMinutes(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_MINUTE);
}
let seconds = 0;
if (direction === TimerType.CountDown) {
seconds = Math.ceil(millis / MILLIS_PER_SECOND);
}
/**
* Converts value in milliseconds to hours
* @param millis
* @returns
*/
export function millisToHours(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_HOUR);
if (direction === TimerType.CountUp) {
seconds = Math.floor(millis / MILLIS_PER_SECOND);
}
// this is there to avoid result giving -0
return seconds === 0 ? 0 : seconds;
}
/**
@@ -1,3 +1,5 @@
import { TimerType } from 'ontime-types';
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
import { formatFromMillis, millisToString, removeLeadingZero } from './timeFormatting';
@@ -13,32 +15,33 @@ describe('millisToString()', () => {
test('negative times are rounded up', () => {
const testScenarios = [
{ millis: 300, expected: '00:00:00' },
{ millis: -300, expected: '-00:00:01' },
{ millis: 1000, expected: '00:00:01' },
{ millis: -1000, expected: '-00:00:01' },
{ millis: 1500, expected: '00:00:01' },
{ millis: -1500, expected: '-00:00:02' },
{ millis: 60000 - 1, expected: '00:00:59' },
{ millis: -(60000 - 1), expected: '-00:01:00' },
{ millis: 60000, expected: '00:01:00' },
{ millis: -60000, expected: '-00:01:00' },
{ millis: 600000, expected: '00:10:00' },
{ millis: -600000, expected: '-00:10:00' },
{ millis: 3600000, expected: '01:00:00' },
{ millis: -3600000, expected: '-01:00:00' },
{ millis: 36000000, expected: '10:00:00' },
{ millis: -36000000, expected: '-10:00:00' },
{ millis: 86399000, expected: '23:59:59' },
{ millis: -86399000, expected: '-23:59:59' },
{ millis: 86400000, expected: '24:00:00' },
{ millis: -86400000, expected: '-24:00:00' },
{ millis: 86401000, expected: '24:00:01' },
{ millis: -86401000, expected: '-24:00:01' },
{ millis: 300, expected_down: '00:00:01', expected_up: '00:00:00' },
{ millis: -300, expected_down: '-00:00:00', expected_up: '-00:00:01' },
{ millis: 1000, expected_down: '00:00:01', expected_up: '00:00:01' },
{ millis: -1000, expected_down: '-00:00:01', expected_up: '-00:00:01' },
{ millis: 1500, expected_down: '00:00:02', expected_up: '00:00:01' },
{ millis: -1500, expected_down: '-00:00:01', expected_up: '-00:00:02' },
{ millis: 60000 - 1, expected_down: '00:01:00', expected_up: '00:00:59' },
{ millis: -(60000 - 1), expected_down: '-00:00:59', expected_up: '-00:01:00' },
{ millis: 60000, expected_down: '00:01:00', expected_up: '00:01:00' },
{ millis: -60000, expected_down: '-00:01:00', expected_up: '-00:01:00' },
{ millis: 600000, expected_down: '00:10:00', expected_up: '00:10:00' },
{ millis: -600000, expected_down: '-00:10:00', expected_up: '-00:10:00' },
{ millis: 3600000, expected_down: '01:00:00', expected_up: '01:00:00' },
{ millis: -3600000, expected_down: '-01:00:00', expected_up: '-01:00:00' },
{ millis: 36000000, expected_down: '10:00:00', expected_up: '10:00:00' },
{ millis: -36000000, expected_down: '-10:00:00', expected_up: '-10:00:00' },
{ millis: 86399000, expected_down: '23:59:59', expected_up: '23:59:59' },
{ millis: -86399000, expected_down: '-23:59:59', expected_up: '-23:59:59' },
{ millis: 86400000, expected_down: '24:00:00', expected_up: '24:00:00' },
{ millis: -86400000, expected_down: '-24:00:00', expected_up: '-24:00:00' },
{ millis: 86401000, expected_down: '24:00:01', expected_up: '24:00:01' },
{ millis: -86401000, expected_down: '-24:00:01', expected_up: '-24:00:01' },
];
testScenarios.forEach((scenario) => {
expect(millisToString(scenario.millis)).toBe(scenario.expected);
expect(millisToString(scenario.millis, { direction: TimerType.CountDown })).toBe(scenario.expected_down);
expect(millisToString(scenario.millis, { direction: TimerType.CountUp })).toBe(scenario.expected_up);
});
});
@@ -48,20 +51,21 @@ describe('millisToString()', () => {
test('random properties', () => {
const testScenarios = [
{ millis: 300, expected: '00:00:00' },
{ millis: 1000, expected: '00:00:01' },
{ millis: 1500, expected: '00:00:01' },
{ millis: 60000, expected: '00:01:00' },
{ millis: 600000, expected: '00:10:00' },
{ millis: 3600000, expected: '01:00:00' },
{ millis: 36000000, expected: '10:00:00' },
{ millis: 86399000, expected: '23:59:59' },
{ millis: 86400000, expected: '24:00:00' },
{ millis: 86401000, expected: '24:00:01' },
{ millis: 300, expected_down: '00:00:01', expected_up: '00:00:00' },
{ millis: 1000, expected_down: '00:00:01', expected_up: '00:00:01' },
{ millis: 1500, expected_down: '00:00:02', expected_up: '00:00:01' },
{ millis: 60000, expected_down: '00:01:00', expected_up: '00:01:00' },
{ millis: 600000, expected_down: '00:10:00', expected_up: '00:10:00' },
{ millis: 3600000, expected_down: '01:00:00', expected_up: '01:00:00' },
{ millis: 36000000, expected_down: '10:00:00', expected_up: '10:00:00' },
{ millis: 86399000, expected_down: '23:59:59', expected_up: '23:59:59' },
{ millis: 86400000, expected_down: '24:00:00', expected_up: '24:00:00' },
{ millis: 86401000, expected_down: '24:00:01', expected_up: '24:00:01' },
];
testScenarios.forEach((scenario) => {
expect(millisToString(scenario.millis)).toBe(scenario.expected);
expect(millisToString(scenario.millis, { direction: TimerType.CountDown })).toBe(scenario.expected_down);
expect(millisToString(scenario.millis, { direction: TimerType.CountUp })).toBe(scenario.expected_up);
});
});
});
@@ -1,4 +1,4 @@
import type { MaybeNumber } from 'ontime-types';
import type { MaybeNumber, TimerType } from 'ontime-types';
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
@@ -8,6 +8,7 @@ export function pad(val: number): string {
type FormatOptions = {
fallback?: string;
direction?: TimerType.CountDown | TimerType.CountUp;
};
/**
@@ -23,7 +24,7 @@ export function millisToString(millis?: MaybeNumber, options?: FormatOptions): s
const isNegative = millis < 0;
const totalSeconds = Math.abs(millisToSeconds(millis));
const totalSeconds = Math.abs(millisToSeconds(millis, options?.direction));
const seconds = totalSeconds % 60;
const minutes = secondsToMinutes(totalSeconds) % 60;
const hours = secondsToHours(totalSeconds);
+39 -92
View File
@@ -132,8 +132,8 @@ importers:
specifier: ^6.0.1
version: 6.0.1
axios:
specifier: ^1.9.0
version: 1.9.0
specifier: ^1.2.0
version: 1.7.2
color:
specifier: ^4.2.3
version: 4.2.3
@@ -288,9 +288,6 @@ importers:
'@googleapis/sheets':
specifier: ^5.0.5
version: 5.0.5
classic-level:
specifier: ^3.0.0
version: 3.0.0
cookie:
specifier: ^1.0.2
version: 1.0.2
@@ -2275,10 +2272,6 @@ packages:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
abstract-level@3.1.0:
resolution: {integrity: sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw==}
engines: {node: '>=18'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2427,8 +2420,8 @@ packages:
resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==}
engines: {node: '>= 0.4'}
axios@1.9.0:
resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==}
axios@1.7.2:
resolution: {integrity: sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==}
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
@@ -2495,9 +2488,6 @@ packages:
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
builder-util-runtime@9.2.4:
resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==}
engines: {node: '>=12.0.0'}
@@ -2594,10 +2584,6 @@ packages:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
classic-level@3.0.0:
resolution: {integrity: sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==}
engines: {node: '>=18'}
cli-truncate@2.1.0:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
@@ -2961,6 +2947,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.0.1:
resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
@@ -3229,6 +3219,10 @@ packages:
resolution: {integrity: sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==}
engines: {node: '>= 18'}
form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
@@ -3584,10 +3578,6 @@ packages:
resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==}
engines: {node: '>= 0.4'}
is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@@ -3756,14 +3746,6 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
level-supports@6.2.0:
resolution: {integrity: sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==}
engines: {node: '>=16'}
level-transcoder@1.0.1:
resolution: {integrity: sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==}
engines: {node: '>=12'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -3847,10 +3829,6 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
maybe-combine-errors@1.0.0:
resolution: {integrity: sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==}
engines: {node: '>=10'}
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -3947,10 +3925,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
module-error@1.0.2:
resolution: {integrity: sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==}
engines: {node: '>=10'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -3971,9 +3945,6 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
napi-macros@2.2.2:
resolution: {integrity: sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -3996,10 +3967,6 @@ packages:
encoding:
optional: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.14:
resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==}
@@ -7156,15 +7123,6 @@ snapshots:
abab@2.0.6:
optional: true
abstract-level@3.1.0:
dependencies:
buffer: 6.0.3
is-buffer: 2.0.5
level-supports: 6.2.0
level-transcoder: 1.0.1
maybe-combine-errors: 1.0.0
module-error: 1.0.2
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -7250,7 +7208,7 @@ snapshots:
ejs: 3.1.9
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
electron-publish: 24.13.1
form-data: 4.0.2
form-data: 4.0.0
fs-extra: 10.1.0
hosted-git-info: 4.1.0
is-ci: 3.0.1
@@ -7359,10 +7317,10 @@ snapshots:
available-typed-arrays@1.0.5: {}
axios@1.9.0:
axios@1.7.2:
dependencies:
follow-redirects: 1.15.6
form-data: 4.0.2
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
@@ -7446,11 +7404,6 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
builder-util-runtime@9.2.4:
dependencies:
debug: 4.3.7
@@ -7529,7 +7482,7 @@ snapshots:
call-bound@1.0.3:
dependencies:
call-bind-apply-helpers: 1.0.1
get-intrinsic: 1.2.7
get-intrinsic: 1.2.6
callsites@3.1.0: {}
@@ -7581,13 +7534,6 @@ snapshots:
ci-info@3.9.0: {}
classic-level@3.0.0:
dependencies:
abstract-level: 3.1.0
module-error: 1.0.2
napi-macros: 2.2.2
node-gyp-build: 4.8.4
cli-truncate@2.1.0:
dependencies:
slice-ansi: 3.0.0
@@ -7957,7 +7903,7 @@ snapshots:
dependencies:
available-typed-arrays: 1.0.5
call-bind: 1.0.2
es-set-tostringtag: 2.1.0
es-set-tostringtag: 2.0.1
es-to-primitive: 1.2.1
function-bind: 1.1.2
function.prototype.name: 1.1.5
@@ -8002,6 +7948,13 @@ snapshots:
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
optional: true
es-set-tostringtag@2.0.1:
dependencies:
get-intrinsic: 1.2.2
has: 1.0.3
has-tostringtag: 1.0.0
es-set-tostringtag@2.1.0:
dependencies:
@@ -8009,6 +7962,7 @@ snapshots:
get-intrinsic: 1.2.7
has-tostringtag: 1.0.2
hasown: 2.0.2
optional: true
es-shim-unscopables@1.0.0:
dependencies:
@@ -8405,12 +8359,19 @@ snapshots:
form-data-encoder@4.0.2: {}
form-data@4.0.0:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
optional: true
forwarded@0.2.0: {}
@@ -8528,6 +8489,7 @@ snapshots:
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
optional: true
get-nonce@1.0.1: {}
@@ -8535,6 +8497,7 @@ snapshots:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
optional: true
get-stream@5.2.0:
dependencies:
@@ -8707,6 +8670,7 @@ snapshots:
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
optional: true
has@1.0.3:
dependencies:
@@ -8844,9 +8808,7 @@ snapshots:
is-boolean-object@1.1.2:
dependencies:
call-bind: 1.0.2
has-tostringtag: 1.0.2
is-buffer@2.0.5: {}
has-tostringtag: 1.0.0
is-callable@1.2.7: {}
@@ -8860,7 +8822,7 @@ snapshots:
is-date-object@1.0.5:
dependencies:
has-tostringtag: 1.0.2
has-tostringtag: 1.0.0
is-extglob@2.1.1: {}
@@ -8874,7 +8836,7 @@ snapshots:
is-number-object@1.0.7:
dependencies:
has-tostringtag: 1.0.2
has-tostringtag: 1.0.0
is-number@7.0.0: {}
@@ -9034,13 +8996,6 @@ snapshots:
dependencies:
readable-stream: 2.3.8
level-supports@6.2.0: {}
level-transcoder@1.0.1:
dependencies:
buffer: 6.0.3
module-error: 1.0.2
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -9111,8 +9066,6 @@ snapshots:
math-intrinsics@1.1.0: {}
maybe-combine-errors@1.0.0: {}
media-typer@0.3.0: {}
merge-descriptors@1.0.3: {}
@@ -9181,8 +9134,6 @@ snapshots:
mkdirp@1.0.4: {}
module-error@1.0.2: {}
ms@2.0.0: {}
ms@2.1.3: {}
@@ -9201,8 +9152,6 @@ snapshots:
nanoid@5.0.7: {}
napi-macros@2.2.2: {}
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
@@ -9219,8 +9168,6 @@ snapshots:
dependencies:
whatwg-url: 5.0.0
node-gyp-build@4.8.4: {}
node-releases@2.0.14: {}
normalize-path@3.0.0: {}
@@ -10329,7 +10276,7 @@ snapshots:
wait-on@7.2.0:
dependencies:
axios: 1.9.0
axios: 1.7.2
joi: 17.13.3
lodash: 4.17.21
minimist: 1.2.8