Compare commits

...

10 Commits

Author SHA1 Message Date
Carlos Valente 9ba5ebd4bd bump version to 4.2.1 (#1896) 2025-11-28 16:26:53 +01:00
Carlos Valente 3f1f06f7c5 Improve Clone (#1897)
* refactor(rundown): optimise copy-paste performance

* chore: configure opt-in compiler

* refactor(rundown): stabilise frequently accessed data

* feat(clone): allow cloning any element

* remove paste above and cue increment from test

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-11-28 16:20:52 +01:00
arc-alex a78586d2fa allow count up to show negative value 2025-11-28 15:03:28 +01:00
arc-alex 8e93b0af25 fix: countToEnd should not override display options 2025-11-28 15:03:28 +01:00
Carlos Valente 9a5d50d933 fix(wakelock): check for feature support 2025-11-28 11:26:23 +01:00
Carlos Valente 1189a94bff refactor(wakelock): simplify logic into hook 2025-11-28 11:26:23 +01:00
Alex Christoffer Rasmussen f128f7acd2 fix: api change custom fields (#1888)
* fix: validate nested custom path

* chore: add test
2025-11-27 09:15:02 +01:00
Carlos Valente 80ec2d1186 refactor(pip): match timer formatting defaults 2025-11-26 20:10:01 +01:00
Carlos Valente 18e6f0d7c4 refactor(timer): allow multiline secondary text 2025-11-26 20:10:01 +01:00
Alex Christoffer Rasmussen 48f2511764 fix: custom fields not persisted (#1891) 2025-11-26 17:58:00 +01:00
39 changed files with 571 additions and 392 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "4.2.0",
"version": "4.2.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.2.0",
"version": "4.2.1",
"private": true,
"type": "module",
"dependencies": {
@@ -17,7 +17,6 @@
"@tanstack/react-table": "^8.21.3",
"autosize": "^6.0.1",
"axios": "^1.12.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"csv-stringify": "^6.6.0",
"prismjs": "^1.30.0",
"react": "^19.1.1",
@@ -66,6 +65,7 @@
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@vitejs/plugin-react": "4.5.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-plugin-jest": "^28.6.0",
+15 -4
View File
@@ -75,7 +75,10 @@ export async function postAddEntry(
/**
* HTTP request to edit an entry
*/
export async function putEditEntry(rundownId: RundownId, data: Partial<OntimeEntry>): Promise<AxiosResponse<OntimeEntry>> {
export async function putEditEntry(
rundownId: RundownId,
data: Partial<OntimeEntry>,
): Promise<AxiosResponse<OntimeEntry>> {
return axios.put(`${rundownPath}/${rundownId}/entry`, data);
}
@@ -107,7 +110,11 @@ export async function patchReorderEntry(rundownId: RundownId, data: ReorderEntry
/**
* HTTP request to swap two events
*/
export async function requestEventSwap(rundownId: RundownId, from: EntryId, to: EntryId): Promise<AxiosResponse<Rundown>> {
export async function requestEventSwap(
rundownId: RundownId,
from: EntryId,
to: EntryId,
): Promise<AxiosResponse<Rundown>> {
return axios.patch(`${rundownPath}/${rundownId}/swap`, { from, to });
}
@@ -121,8 +128,12 @@ export async function requestApplyDelay(rundownId: RundownId, delayId: EntryId):
/**
* HTTP request for cloning an entry
*/
export async function postCloneEntry(rundownId: RundownId, entryId: EntryId): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`);
export async function postCloneEntry(
rundownId: RundownId,
entryId: EntryId,
options?: { before?: EntryId; after?: EntryId },
): Promise<AxiosResponse<Rundown>> {
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
}
/**
@@ -6,7 +6,7 @@ import { Dialog } from '@base-ui-components/react/dialog';
import { useDisclosure, useFullscreen } from '@mantine/hooks';
import { isLocalhost, supportsFullscreen } from '../../../externals';
import { useKeepAwakeOptions } from '../../../features/keep-awake/KeepAwake';
import { canUseWakeLock, useKeepAwakeOptions } from '../../../features/keep-awake/useWakeLock';
import { navigatorConstants } from '../../../viewerConfig';
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
import { useClientStore } from '../../stores/clientStore';
@@ -69,7 +69,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
<IoSwapVertical />
{mirror && <span className={style.note}>Active</span>}
</NavigationMenuItem>
{window.isSecureContext && (
{canUseWakeLock && (
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
Keep Awake
<LuCoffee />
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
EntryId,
InsertOptions,
isOntimeEvent,
isOntimeGroup,
MaybeString,
@@ -173,7 +174,8 @@ export const useEntryActions = () => {
* @private
*/
const { mutateAsync: cloneEntryMutation } = useMutation({
mutationFn: ([rundownId, entryId]: Parameters<typeof postCloneEntry>) => postCloneEntry(rundownId, entryId),
mutationFn: ([rundownId, entryId, options]: Parameters<typeof postCloneEntry>) =>
postCloneEntry(rundownId, entryId, options),
onMutate: () => queryClient.cancelQueries({ queryKey: RUNDOWN }),
onSettled: () => queryClient.invalidateQueries({ queryKey: RUNDOWN }),
});
@@ -182,14 +184,14 @@ export const useEntryActions = () => {
* Clone an entry
*/
const clone = useCallback(
async (entryId: EntryId) => {
async (entryId: EntryId, options?: InsertOptions) => {
try {
const rundownId = getCurrentRundownData()?.id;
if (!rundownId) {
throw new Error('Rundown not initialised');
}
await cloneEntryMutation([rundownId, entryId]);
await cloneEntryMutation([rundownId, entryId, options]);
} catch (error) {
logAxiosError('Error cloning entry', error);
}
@@ -1,68 +0,0 @@
import { EndAction, EntryCustomFields, OntimeEvent, SupportedEntry, TimerType, TimeStrategy } from 'ontime-types';
import { cloneEvent } from '../clone';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original: OntimeEvent = {
id: 'unique',
type: SupportedEntry.Event,
flag: false,
title: 'title',
cue: 'cue',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
timeStrategy: TimeStrategy.LockEnd,
parent: 'test',
linkStart: false,
countToEnd: false,
endAction: EndAction.None,
skip: false,
colour: 'F00',
revision: 10,
timeWarning: 120000,
timeDanger: 60000,
delay: 0,
dayOffset: 0,
gap: 0,
triggers: [],
custom: {
lighting: '3',
} as EntryCustomFields,
};
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
expect(cloned.custom).not.toBe(original.custom);
expect(cloned.triggers).not.toBe(original.triggers);
expect(cloned).toMatchObject({
type: SupportedEntry.Event,
flag: original.flag,
title: original.title,
note: original.note,
timeStart: original.timeStart,
duration: original.duration,
timeEnd: original.timeEnd,
timerType: original.timerType,
timeStrategy: original.timeStrategy,
parent: 'test',
countToEnd: original.countToEnd,
linkStart: original.linkStart,
endAction: original.endAction,
skip: original.skip,
colour: original.colour,
revision: 0,
delay: original.delay,
dayOffset: original.dayOffset,
gap: 0,
timeWarning: original.timeWarning,
timeDanger: original.timeDanger,
triggers: original.triggers,
custom: original.custom,
});
});
});
-36
View File
@@ -1,36 +0,0 @@
import { OntimeEvent, SupportedEntry } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = Omit<OntimeEvent, 'id' | 'cue'>;
export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
return {
type: SupportedEntry.Event,
flag: event.flag,
title: event.title,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
timeStrategy: event.timeStrategy,
countToEnd: event.countToEnd,
linkStart: event.linkStart,
endAction: event.endAction,
skip: event.skip,
colour: event.colour,
parent: event.parent,
revision: 0,
delay: event.delay, // the events will be collocated, so having the same metadata is a good start
dayOffset: event.dayOffset,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
triggers: structuredClone(event.triggers),
custom: structuredClone(event.custom),
};
};
@@ -1,84 +1,10 @@
import { use, useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export default function KeepAwake() {
const { keepAwake } = useKeepAwakeOptions();
const [wakeLockSentinel, setWakeLockSentinel] = useState<WakeLockSentinel | null>(null);
const removeLock = () => {
if (wakeLockSentinel) wakeLockSentinel.release().finally(() => setWakeLockSentinel(null));
};
const acquireLock = () => {
if (!wakeLockSentinel || wakeLockSentinel.released) {
setWakeLockSentinel(null);
navigator.wakeLock
.request('screen')
.then((sentinel) => {
setWakeLockSentinel(sentinel);
})
.catch(console.error);
}
};
useEffect(() => {
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinel !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
return <></>;
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
import { useWakelock } from './useWakeLock';
/**
* Hook exposes the keep awake options
* Composes the wakelock hook to contain re-renders
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
export default function KeepAwake() {
useWakelock();
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake]);
return { keepAwake, toggleKeepAwake };
return null;
}
@@ -0,0 +1,91 @@
import { use, useCallback, useEffect, useMemo, useRef } from 'react';
import { useSearchParams } from 'react-router';
import { PresetContext } from '../../common/context/PresetContext';
// wakelock is only available in secure contexts
// however, that is covered by the navigator check
export const canUseWakeLock = typeof window !== 'undefined' && 'wakeLock' in navigator;
/** @url https://developer.mozilla.org/en-US/docs/Web/API/WakeLock */
export function useWakelock() {
const { keepAwake } = useKeepAwakeOptions();
const wakeLockSentinelRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
const removeLock = () => {
if (wakeLockSentinelRef.current) {
wakeLockSentinelRef.current.release().finally(() => {
wakeLockSentinelRef.current = null;
});
}
};
const acquireLock = () => {
const sentinel = wakeLockSentinelRef.current;
if (!sentinel || sentinel.released) {
wakeLockSentinelRef.current = null;
navigator.wakeLock
.request('screen')
.then((sentinel) => {
wakeLockSentinelRef.current = sentinel;
})
.catch(console.error);
}
};
const controller = new AbortController();
if (keepAwake) {
acquireLock();
document.addEventListener(
'visibilitychange',
() => {
if (wakeLockSentinelRef.current !== null && document.visibilityState === 'visible') {
acquireLock();
}
},
{ signal: controller.signal },
);
} else {
removeLock();
}
return () => {
controller.abort();
removeLock();
};
}, [keepAwake]);
}
const keepAwakeKey = 'keep-awake';
function getOptionsFromParams(searchParams: URLSearchParams, defaultValues?: URLSearchParams) {
// Helper to get value from either source, prioritizing defaultValues
return defaultValues?.has(keepAwakeKey) || searchParams.has(keepAwakeKey);
}
/**
* Hook exposes the keep awake options
*/
export function useKeepAwakeOptions() {
const [searchParams, setSearchParams] = useSearchParams();
const maybePreset = use(PresetContext);
const keepAwake = useMemo(() => {
const defaultValues = maybePreset ? new URLSearchParams(maybePreset.search) : undefined;
return getOptionsFromParams(searchParams, defaultValues);
}, [maybePreset, searchParams]);
const toggleKeepAwake = useCallback(() => {
setSearchParams((searchParams) => {
if (keepAwake) {
searchParams.delete(keepAwakeKey);
} else {
searchParams.set(keepAwakeKey, '1');
}
return searchParams;
});
}, [keepAwake, setSearchParams]);
return { keepAwake, toggleKeepAwake };
}
+39 -26
View File
@@ -1,4 +1,4 @@
import { Fragment, lazy, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TbFlagFilled } from 'react-icons/tb';
import {
closestCenter,
@@ -36,7 +36,6 @@ import { useEntryActions } from '../../common/hooks/useEntryAction';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { useEntryCopy } from '../../common/stores/entryCopyStore';
import { cloneEvent } from '../../common/utils/clone';
import { lastMetadataKey, RundownMetadataObject } from '../../common/utils/rundownMetadata';
import { AppMode, sessionKeys } from '../../ontimeConfig';
@@ -58,6 +57,8 @@ interface RundownProps {
}
export default function Rundown({ data, rundownMetadata }: RundownProps) {
'use memo';
const { order, entries, id } = data;
// we create a copy of the rundown with a data structured aligned with what dnd-kit needs
const featureData = useRundownEditor();
@@ -68,17 +69,20 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
key: `rundown.${id}-editor-collapsed-groups`,
defaultValue: [],
});
const collapsedGroupSet = useMemo(() => new Set(collapsedGroups), [collapsedGroups]);
const { addEntry, deleteEntry, move, reorderEntry } = useEntryActions();
const { entryCopyId, setEntryCopyId } = useEntryCopy();
const { addEntry, clone, deleteEntry, move, reorderEntry } = useEntryActions();
const setEntryCopyId = useEntryCopy((state) => state.setEntryCopyId);
// cursor
const [editorMode] = useSessionStorage<AppMode>({
key: sessionKeys.editorMode,
defaultValue: AppMode.Edit,
});
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setSelectedEvents = useEventSelection((state) => state.setSelectedEvents);
const cursor = useEventSelection((state) => state.cursor);
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -105,20 +109,30 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
);
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(entries, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
(atId: string | null, above = false) => {
// lazily get the value from the store
const { entryCopyId } = useEntryCopy.getState();
if (entryCopyId === null || !entries[entryCopyId]) {
// we cant clone without selection
return;
}
const cloneEntry = entries[copyId];
if (cloneEntry?.type === SupportedEntry.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry);
addEntry(newEvent, { after: adjustedCursor ?? undefined });
let normalisedAtId = atId;
const elementToCopy = entries[entryCopyId];
const refElement = atId ? entries[atId] : undefined;
if (refElement && 'parent' in refElement && refElement.parent && elementToCopy.type === SupportedEntry.Group) {
normalisedAtId = refElement.parent;
}
clone(entryCopyId, {
after: above ? undefined : normalisedAtId ?? undefined,
// if we don't have a cursor add the new event on top
before: above ? normalisedAtId ?? undefined : undefined,
});
},
[addEntry, order, entries],
[entries, clone],
);
/**
@@ -200,9 +214,9 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
*/
const getIsCollapsed = useCallback(
(groupId: EntryId): boolean => {
return Boolean(collapsedGroups.find((id) => id === groupId));
return collapsedGroupSet.has(groupId);
},
[collapsedGroups],
[collapsedGroupSet],
);
/**
@@ -300,12 +314,8 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
],
['mod + C', () => setEntryCopyId(cursor)],
['mod + V', () => insertCopyAtId(cursor, entryCopyId)],
[
'mod + shift + V',
() => insertCopyAtId(cursor, entryCopyId, true),
{ preventDefault: true, usePhysicalKeys: true },
],
['mod + V', () => insertCopyAtId(cursor)],
['mod + shift + V', () => insertCopyAtId(cursor, true), { preventDefault: true, usePhysicalKeys: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true, usePhysicalKeys: true }],
]);
@@ -395,7 +405,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
}
// keep copy of the current state in case we need to revert
const currentEntries = structuredClone(sortableData);
const currentEntries = [...sortableData];
// 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);
@@ -438,9 +448,12 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
return <RundownEmpty handleAddNew={(type: SupportedEntry) => addEntry({ type })} />;
}
// 1. gather presentation options
// gather presentation options
const isEditMode = editorMode === AppMode.Edit;
// gather rundown wide data
const lastEntryId = order.at(-1);
return (
<div className={style.rundownContainer} ref={scrollRef} data-testid='rundown'>
<DndContext
@@ -509,7 +522,7 @@ export default function Rundown({ data, rundownMetadata }: RundownProps) {
const groupColour = entryMetadata.groupColour === '' ? '#9d9d9d' : entryMetadata.groupColour;
const isFirst = index === 0;
const isLast = entryId === order.at(-1);
const isLast = entryId === lastEntryId;
/**
* We need to provide the parent ID for the QuickAdd components
@@ -1,16 +1,4 @@
import {
isOntimeDelay,
isOntimeEvent,
isOntimeMilestone,
OntimeEntry,
OntimeEvent,
Playback,
SupportedEntry,
} from 'ontime-types';
import { useEntryActions } from '../../common/hooks/useEntryAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { cloneEvent } from '../../common/utils/clone';
import { isOntimeDelay, isOntimeEvent, isOntimeMilestone, OntimeEntry, Playback, SupportedEntry } from 'ontime-types';
import RundownDelay from './rundown-delay/RundownDelay';
import RundownEvent from './rundown-event/RundownEvent';
@@ -44,13 +32,6 @@ export default function RundownEntry({
totalGap,
isLinkedToLoaded,
}: RundownEntryProps) {
const { addEntry } = useEntryActions();
const createCloneEvent = useMemoisedFn(() => {
const newEvent = cloneEvent(data as OntimeEvent);
addEntry(newEvent, { after: data.id });
});
if (isOntimeEvent(data)) {
return (
<RundownEvent
@@ -83,7 +64,6 @@ export default function RundownEntry({
dayOffset={data.dayOffset}
totalGap={totalGap}
isLinkedToLoaded={isLinkedToLoaded}
createCloneEvent={createCloneEvent}
hasTriggers={data.triggers.length > 0}
/>
);
@@ -56,7 +56,6 @@ interface RundownEventProps {
dayOffset: number;
totalGap: number;
isLinkedToLoaded: boolean;
createCloneEvent: () => void;
hasTriggers: boolean;
}
@@ -91,10 +90,9 @@ export default function RundownEvent({
totalGap,
isLinkedToLoaded,
hasTriggers,
createCloneEvent,
}: RundownEventProps) {
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { updateEntry, batchUpdateEvents, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActions();
const { selectedEvents, unselect, setSelectedEvents, clearSelectedEvents } = useEventSelection();
const handleRef = useRef<null | HTMLSpanElement>(null);
@@ -172,7 +170,7 @@ export default function RundownEvent({
type: 'item',
label: 'Clone',
icon: IoDuplicateOutline,
onClick: createCloneEvent,
onClick: () => clone(eventId, { after: eventId }),
},
{ type: 'divider' },
{
@@ -11,7 +11,6 @@ import { formatTime } from '../../../common/utils/time';
export function getTimerByType(
freezeEnd: boolean,
timerTypeNow: TimerType,
countToEndNow: boolean,
clock: number,
timerObject: Pick<TimerState, 'current' | 'elapsed'>,
timerTypeOverride?: TimerType,
@@ -22,13 +21,6 @@ export function getTimerByType(
const viewTimerType = timerTypeOverride ?? timerTypeNow;
if (countToEndNow) {
if (timerObject.current === null) {
return null;
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
}
switch (viewTimerType) {
case TimerType.CountDown:
if (timerObject.current === null) {
@@ -36,7 +28,7 @@ export function getTimerByType(
}
return freezeEnd ? Math.max(timerObject.current, 0) : timerObject.current;
case TimerType.CountUp:
return Math.abs(timerObject.elapsed ?? 0);
return timerObject.elapsed;
case TimerType.Clock:
return clock;
case TimerType.None:
@@ -37,7 +37,7 @@
.timer {
opacity: 1;
font-family: var(--timer-font, $viewer-font-family);
font-family: $viewer-font-family;
color: var(--timer-colour, $ui-white);
line-height: 0.9em;
text-align: center;
@@ -72,17 +72,17 @@
.secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 100%;
height: auto;
margin-top: 0.125em;
padding-block: 0.125em;
font-weight: 600;
text-align: center;
color: var(--external-color-override, $external-color);
color: $external-color;
letter-spacing: 0.5px;
line-height: 1em;
line-height: 1;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, $external-color 10%, transparent);
@@ -40,7 +40,7 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const stageTimer = getTimerByType(false, timerTypeNow, countToEndNow, clock, time, timerTypeNow);
const stageTimer = getTimerByType(false, timerTypeNow, clock, time, timerTypeNow);
const display = getFormattedTimer(stageTimer, timerTypeNow, 'min', {
removeSeconds: false,
removeLeadingZero: false,
@@ -59,11 +59,11 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
return null;
})();
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, false, false);
const secondaryContent = getSecondaryDisplay(message, currentAux, 'min', false, true, false);
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, undefined, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const userStyles = {
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
};
@@ -84,11 +84,10 @@ export function PipTimer({ viewSettings }: PipTimerProps) {
>
{display}
</div>
<div
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={12} max={256}>
{secondaryContent}
</FitText>
</div>
</div>
+3 -3
View File
@@ -135,8 +135,8 @@
.secondary {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-height: 100%;
height: auto;
margin-top: 0.125em;
padding-block: 0.125em;
@@ -145,7 +145,7 @@
text-align: center;
color: var(--external-color-override, $external-color);
letter-spacing: 0.5px;
line-height: 1em;
line-height: 1;
transition-property: opacity, height;
transition-duration: $viewer-transition-time;
border-top: 1px solid color-mix(in srgb, var(--external-color-override, $external-color) 10%, transparent);
+6 -7
View File
@@ -102,7 +102,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
// gather timer data
const totalTime = getTotalTime(time.duration, time.addedTime);
const formattedClock = formatTime(clock);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, countToEndNow, clock, time, timerType);
const stageTimer = getTimerByType(freezeOvertime, timerTypeNow, clock, time, timerType);
const display = getFormattedTimer(stageTimer, viewTimerType, localisedMinutes, {
removeSeconds: hideTimerSeconds,
removeLeadingZero: removeLeadingZeros,
@@ -132,7 +132,7 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
// gather presentation styles
const resolvedTimerColour = getTimerColour(viewSettings, timerColour, showWarning, showDanger);
const { timerFontSize, externalFontSize } = getEstimatedFontSize(display, secondaryContent);
const timerFontSize = getEstimatedFontSize(display, secondaryContent);
const userStyles = {
...(keyColour && { '--timer-bg': keyColour }),
...(resolvedTimerColour && { '--timer-colour': resolvedTimerColour }),
@@ -185,11 +185,10 @@ function Timer({ customFields, projectData, isMirrored, settings, viewSettings }
{display}
</div>
)}
<div
className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}
style={{ fontSize: `${externalFontSize}vw` }}
>
{secondaryContent}
<div className={cx(['secondary', !secondaryContent && 'secondary--hidden'])}>
<FitText mode='multi' min={64} max={256}>
{secondaryContent}
</FitText>
</div>
</div>
+4 -11
View File
@@ -50,7 +50,7 @@ const fontSizeMap: { [key: number]: number } = {
* Finds a font size that fits the timer in the screen
* Unfortunately hand tweaked
*/
export function getEstimatedFontSize(stageTimer: string, secondaryContent?: string) {
export function getEstimatedFontSize(stageTimer: string, secondaryContent?: string): number {
const stageTimerCharacters = stageTimer.length;
let timerFontSize = (100 / (stageTimerCharacters - 1)) * 1.25;
@@ -58,20 +58,13 @@ export function getEstimatedFontSize(stageTimer: string, secondaryContent?: stri
timerFontSize = fontSizeMap[stageTimerCharacters];
}
let externalFontSize = timerFontSize * 0.325;
// we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen
if (secondaryContent) {
// we need to shrink the timer if the external is going to be there
// this number has been tweaked to fit in a landscape mobile screen
timerFontSize *= 0.6;
if (secondaryContent.length > 25) {
externalFontSize = (100 / (secondaryContent.length - 1)) * 1.8;
}
}
return {
timerFontSize,
externalFontSize,
};
return timerFontSize;
}
/**
+9 -1
View File
@@ -9,6 +9,10 @@ import { ONTIME_VERSION } from './src/ONTIME_VERSION';
const sentryAuthToken = process.env.SENTRY_AUTH_TOKEN;
const ReactCompilerConfig = {
compilationMode: 'annotation',
};
export default defineConfig({
base: './', // Ontime cloud: we use relative paths to allow them to reference a dynamic base set at runtime
define: {
@@ -16,7 +20,11 @@ export default defineConfig({
'import.meta.env.IS_DOCKER': process.env.NODE_ENV === 'docker',
},
plugins: [
react(),
react({
babel: {
plugins: [['babel-plugin-react-compiler', ReactCompilerConfig]],
},
}),
svgrPlugin(),
sentryAuthToken &&
sentryVitePlugin({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "4.2.0",
"version": "4.2.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/resolver",
"version": "4.0.0",
"version": "4.2.1",
"type": "module",
"repository": "https://github.com/cpvalente/ontime",
"types": "./dist/main.d.ts",
@@ -23,7 +23,7 @@
"tsup": "^8.5.0",
"rimraf": "catalog:",
"typescript": "catalog:",
"ontime-types": "workspace:^4.0.0"
"ontime-types": "workspace:^4.2.1"
},
"files": ["dist"]
}
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "4.2.0",
"version": "4.2.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -60,6 +60,7 @@
"build": "node esbuild.js",
"test": "cross-env IS_TEST=true vitest",
"test:inspect": "cross-env IS_TEST=true vitest --inspect --no-file-parallelism",
"test:pipeline": "cross-env IS_TEST=true vitest run"
}
}
@@ -1564,7 +1564,7 @@ describe('rundownMutation.swap()', () => {
});
describe('rundownMutation.clone()', () => {
it('clones an event and adds it to the rundown', () => {
it('clones at the top level of the rundown', () => {
const testRundown = makeRundown({
order: ['1'],
entries: {
@@ -1583,7 +1583,7 @@ describe('rundownMutation.clone()', () => {
});
});
it('clones an event inside a group and adds it to the rundown', () => {
it('clones an event inside a group', () => {
const testRundown = makeRundown({
order: ['1'],
entries: {
@@ -1621,6 +1621,55 @@ describe('rundownMutation.clone()', () => {
});
expect((testRundown.entries[newEntry.id] as OntimeGroup).entries[0]).not.toBe('1a');
});
it('clones an entry from a group inside another group', () => {
const testRundown = makeRundown({
order: ['group1', 'group2'],
entries: {
group1: makeOntimeGroup({ id: 'group1', entries: ['event1'] }),
group2: makeOntimeGroup({ id: 'group2', entries: ['event2'] }),
event1: makeOntimeEvent({ id: 'event1', cue: 'nested-event', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', cue: 'nested-event', parent: 'group2' }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['event2'], {
after: 'event1',
}) as OntimeEvent;
// new event is added to group
expect(testRundown.entries['group1']).toMatchObject({
entries: ['event1', newEntry.id],
});
// new references the parent group
expect(newEntry.parent).toBe('group1');
// the flat rundown remains unchanged
expect(testRundown.order).toStrictEqual(['group1', 'group2']);
});
it('clones an event and inserts it before another event', () => {
const testRundown = makeRundown({
order: ['1', '2'],
entries: {
'1': makeOntimeEvent({ id: '1', cue: 'event1', parent: null }),
'2': makeOntimeEvent({ id: '2', cue: 'event2', parent: null }),
},
});
const newEntry = rundownMutation.clone(testRundown, testRundown.entries['1'], { before: '2' });
// Verify the rundown order is updated correctly
expect(testRundown.order).toStrictEqual(['1', newEntry.id, '2']);
// Verify the cloned entry is added to the rundown
expect(testRundown.entries[newEntry.id]).toMatchObject({
type: SupportedEntry.Event,
cue: 'event1',
parent: null,
});
});
});
describe('rundownMutation.group()', () => {
@@ -13,6 +13,7 @@ import {
duplicateRundown,
getInsertAfterId,
hasChanges,
makeDeepClone,
} from '../rundown.utils.js';
import { makeOntimeGroup, makeOntimeEvent, makeRundown } from '../__mocks__/rundown.mocks.js';
@@ -265,7 +266,7 @@ describe('getInsertAfterId()', () => {
});
describe('duplicateRundown', () => {
it("duplicates a given rundown", () => {
it('duplicates a given rundown', () => {
const demoRundown = demoDb.rundowns['default'];
const title = 'Duplicated Rundown';
const duplicatedRundown = duplicateRundown(demoRundown, title);
@@ -275,10 +276,51 @@ describe('duplicateRundown', () => {
entries: expect.any(Object),
order: expect.any(Array),
flatOrder: expect.any(Array),
})
});
expect(demoRundown.id).not.toEqual(duplicatedRundown.id);
expect(duplicatedRundown.order.length).toEqual(demoRundown.order.length);
expect(duplicatedRundown.flatOrder.length).toEqual(demoRundown.flatOrder.length);
expect(Object.keys(duplicatedRundown.entries).length).toEqual(Object.keys(demoRundown.entries).length);
})
})
});
});
describe('makeDeepClone()', () => {
it('deep clones a group along with its nested entries', () => {
const group1 = makeOntimeGroup({ id: 'group1', title: 'Group 1', entries: ['event1', 'event2'] });
const rundown = makeRundown({
entries: {
group1,
event1: makeOntimeEvent({ id: 'event1', title: 'Event 1', parent: 'group1' }),
event2: makeOntimeEvent({ id: 'event2', title: 'Event 2', parent: 'group1' }),
},
order: ['group1'],
flatOrder: ['group1', 'event1', 'event2'],
});
const { newGroup, nestedEntries } = makeDeepClone(group1, rundown);
expect(newGroup).toMatchObject({
id: expect.any(String),
title: 'Group 1 (copy)',
entries: [expect.any(String), expect.any(String)],
revision: 0,
});
expect(newGroup.id).not.toEqual('group1');
expect(newGroup.entries.length).toEqual(group1.entries.length);
expect(nestedEntries).toMatchObject([
{
id: expect.any(String),
title: 'Event 1',
parent: newGroup.id,
revision: 0,
},
{
id: expect.any(String),
title: 'Event 2',
parent: newGroup.id,
revision: 0,
},
]);
});
});
+68 -27
View File
@@ -24,23 +24,25 @@ import {
OntimeEvent,
PatchWithId,
Rundown,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { consoleError } from '../../utils/console.js';
import type { RundownMetadata } from './rundown.types.js';
import {
applyPatchToEntry,
cloneGroup,
cloneEntry,
cloneSimpleRundownEntry,
createGroup,
deleteById,
doesInvalidateMetadata,
getInsertAfterId,
getUniqueId,
makeDeepClone,
} from './rundown.utils.js';
import { makeRundownMetadata, ProcessedRundownMetadata } from './rundown.parser.js';
import { consoleError } from '../../utils/console.js';
/**
* The currently loaded rundown in cache
@@ -171,6 +173,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
function add(rundown: Rundown, entry: OntimeEntry, afterId: EntryId | null, parent: OntimeGroup | null): OntimeEntry {
if (parent) {
// 1. inserting an entry inside a group
if ('parent' in entry) {
entry.parent = parent.id;
}
if (afterId) {
const atEventsIndex = parent.entries.indexOf(afterId) + 1;
const atFlatIndex = rundown.flatOrder.indexOf(afterId) + 1;
@@ -439,40 +446,74 @@ function swap(rundown: Rundown, eventFrom: OntimeEvent, eventTo: OntimeEvent) {
* Inserts a clone of the given entry into the rundown
* Handles cloning children if the entry is a group
*/
function clone(rundown: Rundown, entry: OntimeEntry): OntimeEntry {
function clone(rundown: Rundown, entry: OntimeEntry, options?: InsertOptions): OntimeEntry {
if (isOntimeGroup(entry)) {
const newGroup = cloneGroup(entry, getUniqueId(rundown));
const nestedIds: EntryId[] = [];
const { newGroup, nestedEntries } = makeDeepClone(entry, rundown);
for (let i = 0; i < entry.entries.length; i++) {
const nestedEntryId = entry.entries[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new group
const newNestedEntry = cloneEntry(nestedEntry, getUniqueId(rundown));
(newNestedEntry as OntimeEvent | OntimeDelay).parent = newGroup.id;
nestedIds.push(newNestedEntry.id);
// we immediately insert the nested entries into the rundown
rundown.entries[newNestedEntry.id] = newNestedEntry;
// insert all entries into the rundown
rundown.entries[newGroup.id] = newGroup;
for (let i = 0; i < nestedEntries.length; i++) {
const nestedEntry = nestedEntries[i];
rundown.entries[nestedEntry.id] = nestedEntry;
}
// indexes + 1 since we are inserting after the cloned group
const atIndex = rundown.order.indexOf(entry.id) + 1;
// by default we insert after the cloned element
let atIndex = rundown.order.indexOf(entry.id) + 1;
newGroup.entries = nestedIds;
newGroup.title = `${entry.title || 'Untitled'} (copy)`;
const referenceId = options?.after ?? options?.before;
if (referenceId) {
// trying to insert relatively to another entry
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry) {
if (options?.after) {
atIndex = rundown.order.indexOf(referenceId) + 1;
} else if (options?.before) {
atIndex = rundown.order.indexOf(referenceId);
}
}
}
rundown.entries[newGroup.id] = newGroup;
// we only need to insert the group, the nested entries will be resolved by the rundown engine
rundown.order = insertAtIndex(atIndex, newGroup.id, rundown.order);
return newGroup;
} else {
const parent: OntimeGroup | null = entry.parent ? (rundown.entries[entry.parent] as OntimeGroup) : null;
return add(rundown, cloneEntry(entry, getUniqueId(rundown)), entry.id, parent);
const clonedEntry = cloneSimpleRundownEntry(entry, getUniqueId(rundown));
let parent: OntimeGroup | null = null;
// trying to insert relatively to another entry, check that entries parent
const referenceId = options?.after ?? options?.before;
/**
* if we have a positioning reference, and that reference has a parent
* we need to maintain the same parent for the cloned entry
*/
if (referenceId) {
const referenceEntry = rundown.entries[referenceId];
if (referenceEntry && !isOntimeGroup(referenceEntry)) {
if (referenceEntry.parent) {
const maybeParent = rundown.entries[referenceEntry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
}
} else if (entry.parent) {
const maybeParent = rundown.entries[entry.parent];
if (maybeParent && isOntimeGroup(maybeParent)) {
parent = maybeParent;
}
}
// if we have resolved a parent, we add it to the cloned entry
let after = getInsertAfterId(rundown, parent, options?.after, options?.before);
if (!after) {
after = entry.id;
}
return add(rundown, clonedEntry, after, parent);
}
}
@@ -28,6 +28,7 @@ import {
entryReorderValidator,
entrySwapValidator,
validateRundownMutation,
clonePostValidator,
} from './rundown.validation.js';
import { paramsWithId } from '../validation-utils/validationFunction.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
@@ -296,10 +297,14 @@ router.patch(
router.post(
'/:rundownId/clone/:id',
paramsWithId,
clonePostValidator,
validateRundownMutation,
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
try {
const rundown = await cloneEntry(req.params.id);
const rundown = await cloneEntry(req.params.id, {
before: req.body?.before,
after: req.body?.after,
});
res.status(200).send(rundown);
} catch (error) {
const message = getErrorMessage(error);
@@ -14,11 +14,15 @@ import {
Rundown,
LogOrigin,
ProjectRundowns,
InsertOptions,
} from 'ontime-types';
import { customFieldLabelToKey } from 'ontime-utils';
import { updateRundownData } from '../../stores/runtimeState.js';
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
import {
createTransaction,
@@ -29,9 +33,6 @@ import {
} from './rundown.dao.js';
import type { RundownMetadata } from './rundown.types.js';
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
import { logger } from '../../classes/Logger.js';
/**
* creates a new entry with given data
@@ -347,17 +348,18 @@ export async function swapEvents(fromId: EntryId, toId: EntryId): Promise<Rundow
/**
* Clones an entry, ensuring that all dependencies are preserved
* Handles cloning children if the entry is a group
* @throws if the entry to clone does not exist
*/
export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
export async function cloneEntry(entryId: EntryId, options: InsertOptions): Promise<Rundown> {
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
const originalEntry = rundown.entries[entryId];
if (!originalEntry) {
throw new Error('Did not find event to clone');
throw new Error('Could not find entry to clone');
}
const newEntry = rundownMutation.clone(rundown, originalEntry);
const newEntry = rundownMutation.clone(rundown, originalEntry, options);
const { rundown: rundownResult, rundownMetadata, revision } = commit();
// schedule the side effects
@@ -373,7 +375,6 @@ export async function cloneEntry(entryId: EntryId): Promise<Rundown> {
} else if (isOntimeDelay(newEntry)) {
notifyChanges(rundownMetadata, revision, { external: true });
}
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
});
return rundownResult;
@@ -396,20 +396,54 @@ export function cloneGroup(entry: OntimeGroup, newId: EntryId): OntimeGroup {
// in groups, we need to remove the events references
newEntry.entries = [];
newEntry.title = `${entry.title || 'Untitled'} (copy)`;
newEntry.revision = 0;
return newEntry;
}
/**
* Receives an entry and chooses the correct cloning strategy
* Clones a group and all its nested entries
*/
export function cloneEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
export function makeDeepClone(
group: OntimeGroup,
rundown: Rundown,
): { newGroup: OntimeGroup; nestedEntries: OntimeEntry[] } {
const newGroupId = getUniqueId(rundown);
const newGroup = cloneGroup(group, newGroupId);
const nestedEntries: OntimeEntry[] = [];
const nestedEntryIds: EntryId[] = [];
for (let i = 0; i < group.entries.length; i++) {
const nestedEntryId = group.entries[i];
const nestedEntry = rundown.entries[nestedEntryId];
if (!nestedEntry) {
continue;
}
// clone the event and assign it to the new group
const nestedEntryNewId = getUniqueId(rundown);
const newNestedEntry = cloneSimpleRundownEntry(nestedEntry, nestedEntryNewId);
(newNestedEntry as OntimeEvent | OntimeDelay | OntimeMilestone).parent = newGroup.id;
nestedEntryIds.push(nestedEntryNewId);
nestedEntries.push(newNestedEntry);
}
// update the new group with the nested entries
newGroup.entries = nestedEntryIds;
return { newGroup, nestedEntries };
}
/**
* Receives an entry and chooses the correct cloning strategy
* @throws if the source entry is unknown or a group
*/
export function cloneSimpleRundownEntry(entry: OntimeEntry, newId: EntryId): OntimeEntry {
if (isOntimeEvent(entry)) {
return cloneEvent(entry, newId);
} else if (isOntimeDelay(entry)) {
return cloneDelay(entry, newId);
} else if (isOntimeGroup(entry)) {
return cloneGroup(entry, newId);
} else if (isOntimeMilestone(entry)) {
return cloneMilestone(entry, newId);
}
@@ -42,6 +42,13 @@ export const entryPostValidator = [
requestValidationFunction,
];
export const clonePostValidator = [
body('after').optional().isString(),
body('before').optional().isString(),
requestValidationFunction,
];
export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
export const entryBatchPutValidator = [
@@ -0,0 +1,91 @@
import { EntryCustomFields, OntimeEvent } from 'ontime-types';
import { isValidChangeProperty } from '../integration.utils.js';
describe('isValidChangeProperty()', () => {
test('correct value and property', () => {
const testEvent = {
id: 'test',
duration: 111,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'duration', 123)).toBeTruthy();
});
test('correct property and undefined value', () => {
const testEvent = {
id: 'test',
duration: 111,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'duration', undefined)).toBeFalsy();
});
test('missing property and undefined value', () => {
const testEvent = {
id: 'test',
duration: 111,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'missing', 123)).toBeFalsy();
});
test('non existing custom value', () => {
const testEvent = {
id: 'test',
duration: 111,
custom: {
field: 'test',
} as EntryCustomFields,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'custom:test', 123)).toBeFalsy();
});
test('existing custom value', () => {
const testEvent = {
id: 'test',
duration: 111,
custom: {
test: 'test',
} as EntryCustomFields,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'custom:test', 123)).toBeTruthy();
});
test('empty custom definition', () => {
const testEvent = {
id: 'test',
duration: 111,
custom: {
test: 'test',
} as EntryCustomFields,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'custom:', 123)).toBeFalsy();
});
test('build-in in custom', () => {
const testEvent = {
id: 'test',
duration: 111,
custom: {
test: 'test',
} as EntryCustomFields,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'custom:toString', 123)).toBeFalsy();
});
test('build-in in top object', () => {
const testEvent = {
id: 'test',
duration: 111,
custom: {
test: 'test',
} as EntryCustomFields,
} as OntimeEvent;
expect(isValidChangeProperty(testEvent, 'toString', 123)).toBeFalsy();
});
});
@@ -18,7 +18,7 @@ import { validateMessage, validateTimerMessage } from '../services/message-servi
import { runtimeService } from '../services/runtime-service/runtime.service.js';
import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { parseProperty } from './integration.utils.js';
import { parseProperty, isValidChangeProperty } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { coerceEnum } from '../utils/coerceType.js';
@@ -77,10 +77,9 @@ const actionHandlers: Record<ApiActionTag, ActionHandler> = {
let shouldThrottle = false;
Object.entries(data).forEach(([property, value]) => {
if (typeof property !== 'string' || value === undefined || !(property in targetEntry)) {
if (!isValidChangeProperty(targetEntry, property, value)) {
throw new Error('Invalid property or value');
}
// parseProperty is async because of the data lock
const newObjectProperty = parseProperty(property, value);
const key = Object.keys(newObjectProperty)[0];
shouldThrottle = shouldThrottle || willCauseRegeneration(key);
@@ -1,4 +1,4 @@
import { EndAction, TimeStrategy, TimerType, isKeyOfType } from 'ontime-types';
import { EndAction, OntimeEntry, TimeStrategy, TimerType, isKeyOfType } from 'ontime-types';
import { maxDuration } from 'ontime-utils';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
@@ -60,3 +60,14 @@ export function parseProperty(property: string, value: unknown) {
const parserFn = propertyConversion[property];
return { [property]: parserFn(value) };
}
export function isValidChangeProperty(target: OntimeEntry, property: string, value: unknown): boolean {
if (typeof property !== 'string') return false;
if (value === undefined) return false;
if (property.startsWith('custom:') && 'custom' in target) {
const customProperty = property.slice('custom:'.length);
if (!customProperty) return false;
return Object.hasOwn(target.custom, customProperty);
}
return Object.hasOwn(target, property);
}
@@ -316,10 +316,17 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
// we can pass some stuff straight to the data provider
await getDataProvider().mergeIntoData(rest);
// ... but rundown and custom fields need to be checked
if (rundowns != null) {
const customFields = parseCustomFields(data);
const result = parseRundowns(data, customFields);
// the rundown depends on custom fields
// so custom fields needs to be checked first
if (customFields) {
const parsedCustomFields = parseCustomFields(data);
await getDataProvider().mergeIntoData({ customFields: parsedCustomFields });
}
// then we can checked the rundown
if (rundowns) {
const parsedCustomFields = await getDataProvider().getCustomFields();
const result = parseRundowns(data, parsedCustomFields);
/**
* The user may have multiple rundowns
@@ -330,7 +337,7 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
const rundownToLoad =
last?.rundownId && last.rundownId in result ? result[last.rundownId] : getFirstRundown(result);
await initRundown(rundownToLoad, customFields, true);
await initRundown(rundownToLoad, parsedCustomFields, true); //mergeIntoData is handled by the init function
}
const updatedData = await getDataProvider().getData();
@@ -26,17 +26,9 @@ test('Copy-paste', async ({ page }) => {
// assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('5');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4');
// copy paste above
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).click();
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).press('Control+c');
await page.getByTestId('rundown-event').locator('div').filter({ hasText: '5' }).press('Control+Shift+v');
// assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('entry__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('4.1');
//TODO: reintroduce the past above test
});
test('Move', async ({ page }) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.2.0",
"version": "4.2.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.0.0-beta.4",
"version": "4.2.1",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -3,15 +3,14 @@ import type { MaybeNumber } from '../../utils/utils.type.js';
export type PatchWithId<T extends OntimeEntry = OntimeEntry> = Partial<T> & { id: EntryId };
export type EventPostPayload = Partial<OntimeEntry> & {
export type InsertOptions = {
after?: EntryId;
before?: EntryId;
};
}
export type TransientEventPayload = Partial<OntimeEntry> & {
after?: EntryId;
before?: EntryId;
};
export type EventPostPayload = Partial<OntimeEntry> & InsertOptions;
export type TransientEventPayload = Partial<OntimeEntry> & InsertOptions;
export type ProjectRundown = {
id: string;
+1
View File
@@ -79,6 +79,7 @@ export type {
} from './api/ontime-controller/BackendResponse.type.js';
export type {
EventPostPayload,
InsertOptions,
PatchWithId,
ProjectRundown,
ProjectRundownsList,
+17 -26
View File
@@ -137,9 +137,6 @@ importers:
axios:
specifier: ^1.12.2
version: 1.12.2
babel-plugin-react-compiler:
specifier: 19.1.0-rc.3
version: 19.1.0-rc.3
csv-stringify:
specifier: ^6.6.0
version: 6.6.0
@@ -207,6 +204,9 @@ importers:
'@vitejs/plugin-react':
specifier: 4.5.1
version: 4.5.1(vite@6.3.1(@types/node@22.15.26)(jiti@2.4.2)(sass@1.92.0)(tsx@4.20.5))
babel-plugin-react-compiler:
specifier: 1.0.0
version: 1.0.0
eslint:
specifier: 'catalog:'
version: 8.56.0
@@ -295,7 +295,7 @@ importers:
specifier: 'catalog:'
version: 8.56.0
ontime-types:
specifier: workspace:^4.0.0
specifier: workspace:^4.2.1
version: link:../../packages/types
rimraf:
specifier: 'catalog:'
@@ -738,10 +738,6 @@ packages:
resolution: {integrity: sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.27.7':
resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
@@ -2183,8 +2179,8 @@ packages:
axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
babel-plugin-react-compiler@19.1.0-rc.3:
resolution: {integrity: sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==}
babel-plugin-react-compiler@1.0.0:
resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -5380,7 +5376,7 @@ snapshots:
'@babel/parser': 7.23.6
'@babel/template': 7.22.15
'@babel/traverse': 7.23.6
'@babel/types': 7.27.7
'@babel/types': 7.28.2
convert-source-map: 2.0.0
debug: 4.4.1
gensync: 1.0.0-beta.2
@@ -5439,7 +5435,7 @@ snapshots:
'@babel/generator@7.27.5':
dependencies:
'@babel/parser': 7.27.5
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
jsesc: 3.1.0
@@ -5594,7 +5590,7 @@ snapshots:
'@babel/helpers@7.27.4':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@babel/helpers@7.28.3':
dependencies:
@@ -5610,7 +5606,7 @@ snapshots:
'@babel/parser@7.23.6':
dependencies:
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@babel/parser@7.27.5':
dependencies:
@@ -5654,7 +5650,7 @@ snapshots:
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.27.5
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@babel/traverse@7.23.6':
dependencies:
@@ -5677,7 +5673,7 @@ snapshots:
'@babel/generator': 7.27.5
'@babel/parser': 7.27.5
'@babel/template': 7.27.2
'@babel/types': 7.27.7
'@babel/types': 7.28.2
debug: 4.4.1
globals: 11.12.0
transitivePeerDependencies:
@@ -5718,11 +5714,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.27.7':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/types@7.28.2':
dependencies:
'@babel/helper-string-parser': 7.27.1
@@ -6541,7 +6532,7 @@ snapshots:
'@svgr/hast-util-to-babel-ast@8.0.0':
dependencies:
'@babel/types': 7.27.7
'@babel/types': 7.28.2
entities: 4.5.0
'@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.5.3))':
@@ -6607,16 +6598,16 @@ snapshots:
'@types/babel__generator@7.6.8':
dependencies:
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.27.5
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@types/babel__traverse@7.20.4':
dependencies:
'@babel/types': 7.27.7
'@babel/types': 7.28.2
'@types/body-parser@1.19.2':
dependencies:
@@ -7223,7 +7214,7 @@ snapshots:
transitivePeerDependencies:
- debug
babel-plugin-react-compiler@19.1.0-rc.3:
babel-plugin-react-compiler@1.0.0:
dependencies:
'@babel/types': 7.28.2