mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf3d426649 | |||
| 55c6e70f4a | |||
| f9f1679e8c | |||
| fca54ab793 | |||
| 5a38326fb4 | |||
| 108db7c87a | |||
| 68080d0bc6 |
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
-1
@@ -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', () => {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+8
-4
@@ -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])}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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'));
|
||||
@@ -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,4 +1,3 @@
|
||||
|
||||
/**
|
||||
* This CSS file allows user customisation of the UI
|
||||
* We expose some CSS properties to facilitate this (see below in :root)
|
||||
|
||||
@@ -180,7 +180,8 @@
|
||||
"startTime": null,
|
||||
"endTime": null,
|
||||
"duration": 0,
|
||||
"isFirstLinked": false
|
||||
"isFirstLinked": false,
|
||||
"numEvents": 0
|
||||
},
|
||||
"1c420": {
|
||||
"type": "event",
|
||||
|
||||
Vendored
+4
-2
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Generated
+39
-92
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user