Merge commit master into google-sheets-lite

This commit is contained in:
arc-alex
2023-12-12 11:43:10 +01:00
77 changed files with 1740 additions and 402 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "2.16.2",
"version": "2.21.3",
"private": true,
"dependencies": {
"@chakra-ui/react": "^2.7.0",
@@ -5,6 +5,7 @@ export const USERFIELDS = ['userFields'];
export const RUNDOWN = ['rundown'];
export const APP_INFO = ['appinfo'];
export const OSC_SETTINGS = ['oscSettings'];
export const HTTP_SETTINGS = ['httpSettings'];
export const APP_SETTINGS = ['appSettings'];
export const VIEW_SETTINGS = ['viewSettings'];
export const RUNTIME = ['runtimeStore'];
+18
View File
@@ -5,6 +5,7 @@ import {
GetInfo,
GoogleSheet,
GoogleSheetState,
HttpSettings,
OntimeRundown,
OSCSettings,
OscSubscription,
@@ -106,6 +107,23 @@ export async function getOSC(): Promise<OSCSettings> {
return res.data;
}
/**
* @description HTTP request to retrieve http settings
* @return {Promise}
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(`${ontimeURL}/http`);
return res.data;
}
/**
* @description HTTP request to mutate http settings
* @return {Promise}
*/
export async function postHTTP(data: HttpSettings) {
return axios.post(`${ontimeURL}/http`, data);
}
/**
* @description HTTP request to mutate osc settings
* @return {Promise}
@@ -212,6 +212,7 @@ export default function TimeInput(props: TimeInputProps) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
autoComplete='off'
/>
</InputGroup>
);
@@ -10,10 +10,6 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 48px;
.mirror {
transform: rotate(180deg);
}
.buttonContainer {
display: flex;
flex-direction: column;
@@ -12,7 +12,6 @@ import { IoSwapVertical } from '@react-icons/all-files/io5/IoSwapVertical';
import { navigatorConstants } from '../../../viewerConfig';
import useClickOutside from '../../hooks/useClickOutside';
import useFullscreen from '../../hooks/useFullscreen';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useViewOptionsStore } from '../../stores/viewOptions';
import RenameClientModal from './rename-client-modal/RenameClientModal';
@@ -23,18 +22,19 @@ function NavigationMenu() {
const location = useLocation();
const { isFullScreen, toggleFullScreen } = useFullscreen();
const { mirror, toggleMirror } = useViewOptionsStore();
const { toggleMirror } = useViewOptionsStore();
const [showButton, setShowButton] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useClickOutside(menuRef, () => setShowMenu(false));
const { isOpen, onOpen, onClose } = useDisclosure();
const toggleMenu = () => setShowMenu((prev) => !prev);
useKeyDown(toggleMenu, ' ', { isDisabled: searchParams.get('edit') === 'true' || isOpen });
// show on mouse move
useEffect(() => {
let fadeOut: NodeJS.Timeout | null = null;
const setShowMenuTrue = () => {
@@ -63,7 +63,7 @@ function NavigationMenu() {
};
return createPortal(
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
<div id='navigation-menu-portal' ref={menuRef}>
<RenameClientModal isOpen={isOpen} onClose={onClose} />
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Input, Select, Switch } from '@chakra-ui/react';
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
import { isStringBoolean } from '../../utils/viewUtils';
@@ -9,16 +9,22 @@ interface EditFormInputProps {
paramField: ParamField;
}
export default function ParamInput({ paramField }: EditFormInputProps) {
export default function ParamInput(props: EditFormInputProps) {
const [searchParams] = useSearchParams();
const { id, type } = paramField;
const { paramField } = props;
const { id, type, defaultValue } = paramField;
if (type === 'option') {
const optionFromParams = searchParams.get(id);
const defaultOptionValue = optionFromParams || undefined;
const defaultOptionValue = optionFromParams || defaultValue;
return (
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
<Select
placeholder={defaultValue ? undefined : 'Select an option'}
variant='ontime'
name={id}
defaultValue={defaultOptionValue}
>
{Object.entries(paramField.values).map(([key, value]) => (
<option key={key} value={key}>
{value}
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
}
if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
// checked value should be 'true', so it can be captured by the form event
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
}
if (type === 'number') {
const defaultNumberValue = searchParams.get(id) ?? '';
const { prefix, placeholder } = paramField;
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input
type='number'
step='any'
variant='ontime-filled'
name={id}
defaultValue={defaultNumberValue}
placeholder={placeholder}
/>
</InputGroup>
);
}
const defaultStringValue = searchParams.get(id) ?? '';
const defaultStringValue = searchParams.get(id) ?? defaultValue;
const { prefix, placeholder } = paramField;
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
return (
<InputGroup variant='ontime-filled'>
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
</InputGroup>
);
}
@@ -22,9 +22,15 @@ import style from './ViewParamsEditor.module.scss';
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
type SavedViewParams = Record<string, ViewParamsObj>;
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
if (typeof value === 'string' && value.length) {
if (defaultValues.includes(value)) {
return newSearchParams;
}
newSearchParams.set(id, value);
return newSearchParams;
@@ -32,6 +38,7 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
return newSearchParams;
}, new URLSearchParams());
};
interface EditFormDrawerProps {
paramFields: ParamField[];
@@ -72,31 +79,30 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
*/
const onEditDrawerClose = () => {
const onCloseWithoutSaving = () => {
onClose();
searchParams.delete('edit');
setSearchParams(searchParams);
};
const clearParams = () => {
const resetParams = () => {
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
setSearchParams();
onClose();
};
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
formEvent.preventDefault();
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
const newSearchParams = getURLSearchParamsFromObj(newParamsObject);
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
setSearchParams(newSearchParams);
};
return (
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
<DrawerOverlay />
<DrawerContent>
<DrawerHeader className={style.drawerHeader}>
@@ -119,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
</DrawerBody>
<DrawerFooter className={style.drawerFooter}>
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
Clear
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
Reset
</Button>
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
Cancel
</Button>
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
@@ -1,46 +1,56 @@
import { UserFields } from 'ontime-types';
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
import { ParamField } from './types';
export const TIME_FORMAT_OPTION: ParamField = {
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
id: 'format',
title: '12 / 24 hour timer',
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
type: 'option',
values: { '12': '12 hour AM/PM', '24': '24 hour' },
};
defaultValue: timeFormat,
});
export const CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -48,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -61,46 +73,53 @@ export const CLOCK_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
];
export const TIMER_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Presenter Message',
description: 'Prevents the screen from displaying messages from the presenter',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide External',
description: 'Prevents the screen from displaying the external field',
type: 'boolean',
defaultValue: false,
},
];
@@ -109,31 +128,39 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
id: 'key',
title: 'Key Colour',
description: 'Background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffff (default)',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Colour of text background in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000000 (default)',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
@@ -141,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
@@ -154,145 +183,162 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
];
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
{
id: 'preset',
title: 'Preset',
description: 'Selects a style preset (0-1)',
type: 'number',
},
{
id: 'size',
title: 'Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 5)',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: 'fffffa (default)',
},
{
id: 'bg',
title: 'Text Background',
description: 'Text background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Screen background colour in hexadecimal',
prefix: '#',
type: 'string',
placeholder: '00000033 (default)',
},
{
id: 'fadeout',
title: 'Fadeout',
description: 'Time (in seconds) the lower third displays before fading out',
type: 'number',
placeholder: '3 (default)',
},
];
export const BACKSTAGE_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const PUBLIC_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overlow',
type: 'number',
placeholder: '7 (default)',
},
];
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
TIME_FORMAT_OPTION,
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
getTimeOption(timeFormat),
{
id: 'seconds',
title: 'Show Seconds',
description: 'Shows seconds in clock',
type: 'boolean',
defaultValue: false,
},
];
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
return [
TIME_FORMAT_OPTION,
getTimeOption(timeFormat),
{
id: 'showseconds',
title: 'Show seconds',
description: 'Schedule shows hh:mm:ss',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'main',
@@ -4,9 +4,13 @@ type BaseField = {
description: string;
};
type OptionsField = { type: 'option'; values: Record<string, string> };
type StringField = { type: 'string' };
type BooleanField = { type: 'boolean' };
type NumberField = { type: 'number' };
type OptionsField = {
type: 'option';
values: Record<string, string>;
defaultValue?: string;
};
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField);
@@ -0,0 +1,33 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { HttpSettings } from 'ontime-types';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
import { getHTTP, postHTTP } from '../api/ontimeApi';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: httpPlaceholder,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
// we need to jump through some hoops because of the type op port
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
+2 -2
View File
@@ -75,8 +75,8 @@ export const setPlayback = {
reload: () => {
socketSendJson('reload');
},
delay: (amount: number) => {
socketSendJson('delay', amount);
addTime: (amount: number) => {
socketSendJson('addtime', amount);
},
};
+11 -24
View File
@@ -1,26 +1,13 @@
export const httpPlaceholder = {
onLoad: {
url: '',
enabled: false,
},
onStart: {
url: '',
enabled: false,
},
onUpdate: {
url: '',
enabled: false,
},
onPause: {
url: '',
enabled: false,
},
onStop: {
url: '',
enabled: false,
},
onFinish: {
url: '',
enabled: false,
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onUpdate: [],
onPause: [],
onStop: [],
onFinish: [],
},
};
@@ -1,4 +1,4 @@
import { isIPAddress, isOnlyNumbers } from '../regex';
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
describe('simple tests for regex', () => {
test('isOnlyNumbers', () => {
@@ -24,4 +24,16 @@ describe('simple tests for regex', () => {
expect(isIPAddress.test(t)).toBe(false);
});
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
});
wrong.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(false);
});
});
});
@@ -1,4 +1,4 @@
import { cx } from '../styleUtils';
import { cx, getAccessibleColour } from '../styleUtils';
import style from './styleUtils.module.scss';
@@ -13,3 +13,24 @@ describe('cx()', () => {
expect(merged).toMatchSnapshot();
});
});
describe('getAccessibleColour()', () => {
it('handles named colours', () => {
const colour = 'red';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#FF0000FF');
expect(color).toBe('#fffffa');
});
it('handles hex colours', () => {
const colour = '#0F0';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#00FF00FF');
expect(color).toBe('black');
});
it('handles transparens', () => {
const colour = '#0F08';
const { backgroundColor, color } = getAccessibleColour(colour);
expect(backgroundColor).toBe('#0C940CFF');
expect(color).toBe('#fffffa');
});
});
+1
View File
@@ -1,2 +1,3 @@
export const isOnlyNumbers = /^\d+$/;
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
export const startsWithHttp = /^http:\/\//;
+5 -3
View File
@@ -13,13 +13,15 @@ type ColourCombination = {
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
if (bgColour) {
try {
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
return { backgroundColor: bgColour, color: textColor };
const originalColour = Color(bgColour);
const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
} catch (_error) {
/* we do not handle errors here */
}
}
return { backgroundColor: '#000', color: '#fffffa' };
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
};
/**
@@ -86,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
)}
<div className={style.btn}>
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
-1
</TapButton>
</Tooltip>
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
+1
</TapButton>
</Tooltip>
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
-5
</TapButton>
</Tooltip>
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
<TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
+5
</TapButton>
</Tooltip>
@@ -5,6 +5,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundo
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
@@ -120,8 +121,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
} else if (row.original.colour) {
try {
// the colour is user defined and might be invalid
const colour = new Color(row.original.colour).alpha(0.25);
rowBgColour = colour.hsl().string();
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
} catch (_error) {
/* we do not handle errors here */
}
@@ -19,8 +19,8 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
const bgColour = colour;
const textColour = getAccessibleColour(bgColour);
const textColour = getAccessibleColour(colour);
const bgColour = textColour.backgroundColor;
useLayoutEffect(() => {
const observer = new IntersectionObserver(
@@ -55,7 +55,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex}
</td>
{isVisible ? children : null}
@@ -73,8 +73,8 @@ export default function EventEditor() {
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid "${event.id}"`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue "${event.cue}"`}</CopyTag>
</EventEditorDataRight>
</div>
);
@@ -40,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
autoComplete='off'
/>
</div>
);
@@ -5,6 +5,7 @@
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: $inner-section-text-size;
.title {
white-space: nowrap;
@@ -14,12 +15,12 @@
.selected {
min-width: max-content;
font-size: $inner-section-text-size;
color: $label-gray;
}
}
.description {
font-size: $inner-section-text-size;
color: $label-gray;
white-space: nowrap;
overflow: hidden;
@@ -2,8 +2,9 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r
import ModalWrapper from '../ModalWrapper';
import OscIntegration from './OscIntegration';
import OscSettings from './OscSettings';
import HttpIntegration from './http/HttpIntegration';
import OscIntegration from './osc/OscIntegration';
import OscSettings from './osc/OscSettings';
import styles from '../Modal.module.scss';
@@ -30,6 +31,7 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabList>
<Tab>OSC</Tab>
<Tab>OSC Integration</Tab>
<Tab>HTTP Integration</Tab>
</TabList>
<TabPanels>
<TabPanel>
@@ -38,6 +40,9 @@ export default function IntegrationModal(props: IntegrationModalProps) {
<TabPanel>
<OscIntegration />
</TabPanel>
<TabPanel>
<HttpIntegration />
</TabPanel>
</TabPanels>
</Tabs>
</ModalBody>
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Switch } from '@chakra-ui/react';
import type { HttpSettings } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { OntimeCycle, sectionText } from '../integration.utils';
import HttpSubscriptionRow from './HttpSubscriptionRow';
import styles from '../../Modal.module.scss';
export default function HttpIntegration() {
const { data, isFetching } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const { emitError } = useEmitLog();
const {
control,
handleSubmit,
register,
reset,
formState: { isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onBlur',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const resetForm = () => {
reset(data);
};
const onSubmit = async (values: HttpSettings) => {
try {
const newSettings: HttpSettings = {
enabledOut: Boolean(values.enabledOut),
subscriptions: {
onLoad: values.subscriptions.onLoad ?? [],
onStart: values.subscriptions.onStart ?? [],
onPause: values.subscriptions.onPause ?? [],
onStop: values.subscriptions.onStop ?? [],
onUpdate: values.subscriptions.onUpdate ?? [],
onFinish: values.subscriptions.onFinish ?? [],
},
};
await mutateAsync(newSettings);
} catch (error) {
emitError(`Error setting HTML: ${error}`);
}
};
if (isFetching) {
return <ModalLoader />;
}
const placeholder = 'http://x.x.x.x:xxxx/api/path';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
<div className={styles.splitSection}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
</div>
<Switch {...register('enabledOut')} variant='ontime-on-light' />
</div>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onLoad}
title={sectionText.onLoad.title}
subtitle={sectionText.onLoad.subtitle}
visible={showSection === TimerLifeCycle.onLoad}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStart}
title={sectionText.onStart.title}
subtitle={sectionText.onStart.subtitle}
visible={showSection === TimerLifeCycle.onStart}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onPause}
title={sectionText.onPause.title}
subtitle={sectionText.onPause.subtitle}
visible={showSection === TimerLifeCycle.onPause}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onStop}
title={sectionText.onStop.title}
subtitle={sectionText.onStop.subtitle}
visible={showSection === TimerLifeCycle.onStop}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
title={sectionText.onUpdate.title}
subtitle={sectionText.onUpdate.subtitle}
visible={showSection === TimerLifeCycle.onUpdate}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<HttpSubscriptionRow
cycle={TimerLifeCycle.onFinish}
title={sectionText.onFinish.title}
subtitle={sectionText.onFinish.subtitle}
visible={showSection === TimerLifeCycle.onFinish}
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='http-subscriptions'
handleRevert={resetForm}
isDirty={isDirty}
isValid={isValid}
isSubmitting={isSubmitting}
/>
</form>
);
}
@@ -0,0 +1,95 @@
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { HttpSettings, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../../common/stores/logger';
import { startsWithHttp } from '../../../../common/utils/regex';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface SubscriptionRowProps {
cycle: TimerLifeCycle;
title: string;
subtitle: string;
visible: boolean;
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<HttpSettings>;
control: Control<HttpSettings>;
placeholder: string;
}
export default function SubscriptionRow(props: SubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: `subscriptions.${cycle}`,
control,
});
const hasTooManyOptions = fields.length >= 3;
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
const handleAddNew = () => {
if (hasTooManyOptions) {
emitError(`Maximum amount of ${cycle} subscriptions reached (3)`);
return;
}
append({
message: '',
enabled: false,
});
};
return (
<>
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
<div>
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
</div>
<FiChevronUp className={visible ? collapseStyles.moreCollapsed : collapseStyles.moreExpanded} />
</div>
{visible && (
<>
{fields.map((subscription, index) => (
<div key={subscription.id} className={styles.entryRow}>
<IconButton
icon={<IoRemove />}
onClick={() => remove(index)}
aria-label='delete'
size='xs'
colorScheme='red'
/>
<Input
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
{...register(`subscriptions.${cycle}.${index}.message`, {
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
})}
/>
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
</div>
))}
<Button
onClick={handleAddNew}
className={styles.shiftRight}
isDisabled={hasTooManyOptions}
size='xs'
colorScheme='blue'
variant='outline'
padding='0 2em'
>
Add new
</Button>
</>
)}
</>
);
}
@@ -0,0 +1,30 @@
import { TimerLifeCycle } from 'ontime-types';
export type OntimeCycle = keyof typeof TimerLifeCycle;
export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
@@ -3,43 +3,15 @@ import { useForm } from 'react-hook-form';
import type { OscSubscription } from 'ontime-types';
import { TimerLifeCycle } from 'ontime-types';
import useOscSettings, { usePostOscSubscriptions } from '../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import { type OntimeCycle, sectionText } from '../integration.utils';
import OscSubscriptionRow from './OscSubscriptionRow';
import styles from '../Modal.module.scss';
type OntimeCycle = keyof typeof TimerLifeCycle;
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
onLoad: {
title: 'On Load',
subtitle: 'Triggers when a timer is loaded',
},
onStart: {
title: 'On Start',
subtitle: 'Triggers when a timer starts',
},
onPause: {
title: 'On Pause',
subtitle: 'Triggers when a running timer is paused',
},
onStop: {
title: 'On Stop',
subtitle: 'Triggers when a running timer is stopped',
},
onUpdate: {
title: 'On Every Second',
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
},
onFinish: {
title: 'On Finish',
subtitle: 'Triggers when a running reaches 0',
},
};
import styles from '../../Modal.module.scss';
export default function OscIntegration() {
const { data, isFetching } = useOscSettings();
@@ -92,6 +64,7 @@ export default function OscIntegration() {
return <ModalLoader />;
}
const placeholder = 'OSC message';
return (
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
<OscSubscriptionRow
@@ -102,6 +75,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStart}
@@ -111,6 +85,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onPause}
@@ -120,6 +95,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onStop}
@@ -129,6 +105,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onUpdate}
@@ -138,6 +115,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OscSubscriptionRow
cycle={TimerLifeCycle.onFinish}
@@ -147,6 +125,7 @@ export default function OscIntegration() {
setShowSection={setShowSection}
register={register}
control={control}
placeholder={placeholder}
/>
<OntimeModalFooter
formId='osc-subscriptions'
@@ -4,14 +4,14 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { FormControl, Input, Switch } from '@chakra-ui/react';
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../common/models/OscSettings';
import { useEmitLog } from '../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
import ModalLoader from '../modal-loader/ModalLoader';
import OntimeModalFooter from '../OntimeModalFooter';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { PlaceholderSettings } from '../../../../common/models/OscSettings';
import { useEmitLog } from '../../../../common/stores/logger';
import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex';
import ModalLoader from '../../modal-loader/ModalLoader';
import OntimeModalFooter from '../../OntimeModalFooter';
import styles from '../Modal.module.scss';
import styles from '../../Modal.module.scss';
export default function OscSettings() {
const { data, isFetching } = useOscSettings();
@@ -2,12 +2,12 @@ import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { OscSubscription, TimerLifeCycle } from 'ontime-types';
import type { OscSubscription, TimerLifeCycle } from 'ontime-types';
import { useEmitLog } from '../../../common/stores/logger';
import { useEmitLog } from '../../../../common/stores/logger';
import collapseStyles from '../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../Modal.module.scss';
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
import styles from '../../Modal.module.scss';
interface OscSubscriptionRowProps {
cycle: TimerLifeCycle;
@@ -17,10 +17,11 @@ interface OscSubscriptionRowProps {
setShowSection: (cycle: TimerLifeCycle) => void;
register: UseFormRegister<OscSubscription>;
control: Control<OscSubscription>;
placeholder: string;
}
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
const { cycle, title, subtitle, visible, setShowSection, register, control } = props;
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
const { emitError } = useEmitLog();
const { fields, append, remove } = useFieldArray({
name: cycle,
@@ -64,7 +65,7 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
colorScheme='red'
/>
<Input
placeholder='OSC Message'
placeholder={placeholder}
size='xs'
variant='ontime-filled-on-light'
autoComplete='off'
@@ -11,6 +11,7 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useOperator } from '../../common/hooks/useSocket';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useUserFields from '../../common/hooks-query/useUserFields';
import { debounce } from '../../common/utils/debounce';
import { isStringBoolean } from '../../common/utils/viewUtils';
@@ -40,6 +41,7 @@ export default function Operator() {
const featureData = useOperator();
const [searchParams] = useSearchParams();
const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false);
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
@@ -127,7 +129,7 @@ export default function Operator() {
const subscribedAlias = subscribe ? userFields[subscribe] : '';
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
const operatorOptions = getOperatorOptions(userFields);
const operatorOptions = getOperatorOptions(userFields, settings?.timeFormat ?? '24');
let isPast = Boolean(featureData.selectedEventId);
const hidePast = isStringBoolean(searchParams.get('hidepast'));
@@ -1,10 +1,11 @@
import { ComponentType, useMemo } from 'react';
import { TimeManagerType } from 'common/models/TimeManager.type';
import { Message, OntimeEvent, ProjectData, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
import { useStore } from 'zustand';
import useProjectData from '../../common/hooks-query/useProjectData';
import useRundown from '../../common/hooks-query/useRundown';
import useSettings from '../../common/hooks-query/useSettings';
import useViewSettings from '../../common/hooks-query/useViewSettings';
import { runtime } from '../../common/stores/runtime';
import { useViewOptionsStore } from '../../common/stores/viewOptions';
@@ -27,6 +28,7 @@ type WithDataProps = {
nextId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
onAir: boolean;
};
@@ -43,6 +45,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
const { data: rundownData } = useRundown();
const { data: project } = useProjectData();
const { data: viewSettings } = useViewSettings();
const { data: settings } = useSettings();
const publicEvents = useMemo(() => {
if (Array.isArray(rundownData)) {
@@ -104,6 +107,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
selectedId={selectedId}
publicSelectedId={publicSelectedId}
viewSettings={viewSettings}
settings={settings}
nextId={nextId}
general={project}
onAir={onAir}
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,7 +11,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -37,10 +37,12 @@ interface BackstageProps {
selectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Backstage(props: BackstageProps) {
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings, settings } =
props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [blinkClass, setBlinkClass] = useState(false);
@@ -89,11 +91,12 @@ export default function Backstage(props: BackstageProps) {
}
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const backstageOptions = getBackstageOptions(settings?.timeFormat ?? '24');
return (
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
<ViewParamsEditor paramFields={backstageOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -137,7 +140,7 @@ export default function Backstage(props: BackstageProps) {
{isNegative ? (
<div className='aux-timers__value'>{expectedFinish}</div>
) : (
<SuperscriptTime time={startedAt} className='aux-timers__value' />
<SuperscriptTime time={expectedFinish} className='aux-timers__value' />
)}
</div>
<div className='timer-gap' />
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ViewSettings } from 'ontime-types';
import { Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -18,6 +18,7 @@ interface ClockProps {
isMirrored: boolean;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
const formatOptions = {
@@ -26,7 +27,7 @@ const formatOptions = {
};
export default function Clock(props: ClockProps) {
const { isMirrored, time, viewSettings } = props;
const { isMirrored, time, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
@@ -124,6 +125,8 @@ export default function Clock(props: ClockProps) {
const clock = formatTime(time.clock, formatOptions);
const clean = clock.replace('/:/g', '');
const clockOptions = getClockOptions(settings?.timeFormat ?? '24');
return (
<div
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
@@ -135,7 +138,7 @@ export default function Clock(props: ClockProps) {
data-testid='clock-view'
>
<NavigationMenu />
<ViewParamsEditor paramFields={CLOCK_OPTIONS} />
<ViewParamsEditor paramFields={clockOptions} />
<SuperscriptTime
time={clock}
className='clock'
@@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent, ViewSettings } from 'ontime-types';
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -34,10 +34,11 @@ interface CountdownProps {
time: TimeManagerType;
selectedId: string | null;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Countdown(props: CountdownProps) {
const { isMirrored, backstageEvents, time, selectedId, viewSettings } = props;
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const [searchParams] = useSearchParams();
const { getLocalizedString } = useTranslation();
@@ -109,10 +110,12 @@ export default function Countdown(props: CountdownProps) {
isSelected || runningMessage === TimerMessage.waiting,
);
const timeOption = getTimeOption(settings?.timeFormat ?? '24');
return (
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
<ViewParamsEditor paramFields={[timeOption]} />
{follow === null ? (
<CountdownSelect events={backstageEvents} />
) : (
@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
@@ -9,7 +9,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -35,11 +35,22 @@ interface BackstageProps {
publicSelectedId: string | null;
general: ProjectData;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Public(props: BackstageProps) {
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
props;
const {
isMirrored,
publ,
publicEventNow,
publicEventNext,
time,
events,
publicSelectedId,
general,
viewSettings,
settings,
} = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
@@ -56,10 +67,12 @@ export default function Public(props: BackstageProps) {
const clock = formatTime(time.clock, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
const publicOptions = getPublicOptions(settings?.timeFormat ?? '24');
return (
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
<ViewParamsEditor paramFields={publicOptions} />
<div className='project-header'>
{general.title}
<div className='clock-container'>
@@ -1,12 +1,12 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import { STUDIO_CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
@@ -32,10 +32,11 @@ interface StudioClockProps {
nextId: string | null;
onAir: boolean;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function StudioClock(props: StudioClockProps) {
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
// deferring rendering seems to affect styling (font and useFitText)
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
@@ -75,10 +76,12 @@ export default function StudioClock(props: StudioClockProps) {
const secondsNow = secondsInMillis(time.clock);
const isNegative = (time.current ?? 0) < 0;
const studioClockOptions = getStudioClockOptions(settings?.timeFormat ?? '24');
return (
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={STUDIO_CLOCK_OPTIONS} />
<ViewParamsEditor paramFields={studioClockOptions} />
<div className='clock-container'>
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
<div
@@ -1,13 +1,13 @@
import { useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { Message, OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Message, OntimeEvent, Playback, Settings, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/apiConstants';
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
import TitleCard from '../../../common/components/title-card/TitleCard';
import { TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
@@ -48,10 +48,11 @@ interface TimerProps {
eventNext: OntimeEvent | null;
time: TimeManagerType;
viewSettings: ViewSettings;
settings: Settings | undefined;
}
export default function Timer(props: TimerProps) {
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
@@ -128,10 +129,12 @@ export default function Timer(props: TimerProps) {
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const timerOptions = getTimerOptions(settings?.timeFormat ?? '24');
return (
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
<NavigationMenu />
<ViewParamsEditor paramFields={TIMER_OPTIONS} />
<ViewParamsEditor paramFields={timerOptions} />
{!userOptions.hideMessage && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.16.2",
"version": "2.21.3",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "2.16.2",
"version": "2.21.3",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -13,6 +13,7 @@
"express-session": "^1.17.3",
"express-static-gzip": "^2.1.7",
"express-validator": "^6.14.2",
"got": "^14.0.0",
"google-auth-library": "^9.2.0",
"lowdb": "^5.0.5",
"multer": "^1.4.5-lts.1",
+27 -3
View File
@@ -3,7 +3,7 @@ import { LogOrigin, OSCSettings } from 'ontime-types';
import { Server } from 'node-osc';
import { IAdapter } from './IAdapter.js';
import { dispatchFromAdapter } from '../controllers/integrationController.js';
import { dispatchFromAdapter, type ChangeOptions } from '../controllers/integrationController.js';
import { logger } from '../classes/Logger.js';
export class OscServer implements IAdapter {
@@ -36,12 +36,36 @@ export class OscServer implements IAdapter {
return;
}
let transformedPayload: unknown = args;
// we need to transform the params for the change endpoint
// OSC: ontime/change/{eventID}/{propertyName} value
if (path === 'change') {
if (params.length < 2) {
logger.error(LogOrigin.Rx, 'OSC IN: No params provided for change');
return;
}
if (args === undefined) {
logger.error(LogOrigin.Rx, 'OSC IN: No valid payload provided for change');
return;
}
const eventId = params[0];
const property = params[1];
const value: string | number | boolean = args as string | number | boolean;
transformedPayload = {
eventId,
property,
value,
} satisfies ChangeOptions;
}
try {
const reply = dispatchFromAdapter(
path,
{
payload: args,
params,
payload: transformedPayload,
},
'osc',
);
+2 -1
View File
@@ -17,6 +17,7 @@
import { LogOrigin } from 'ontime-types';
import { WebSocket, WebSocketServer } from 'ws';
import type { Server } from 'http';
import getRandomName from '../utils/getRandomName.js';
import { IAdapter } from './IAdapter.js';
@@ -43,7 +44,7 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server) {
init(server: Server) {
this.wss = new WebSocketServer({ path: '/ws', server });
this.wss.on('connection', (ws) => {
+22 -12
View File
@@ -1,9 +1,9 @@
import { LogOrigin, OSCSettings } from 'ontime-types';
import { HttpSettings, LogOrigin, OSCSettings } from 'ontime-types';
import 'dotenv/config';
import express from 'express';
import expressStaticGzip from 'express-static-gzip';
import http from 'http';
import http, { type Server } from 'http';
import cors from 'cors';
// import utils
@@ -29,6 +29,7 @@ import { eventLoader } from './classes/event-loader/EventLoader.js';
import { integrationService } from './services/integration-service/IntegrationService.js';
import { logger } from './classes/Logger.js';
import { oscIntegration } from './services/integration-service/OscIntegration.js';
import { httpIntegration } from './services/integration-service/HttpIntegration.js';
import { populateStyles } from './modules/loadStyles.js';
import { eventStore, getInitialPayload } from './stores/EventStore.js';
import { PlaybackService } from './services/PlaybackService.js';
@@ -107,8 +108,8 @@ enum OntimeStartOrder {
}
let step = OntimeStartOrder.InitAssets;
let expressServer = null;
let oscServer = null;
let expressServer: Server | null = null;
let oscServer: OscServer | null = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
@@ -165,7 +166,6 @@ export const startServer = async () => {
};
/**
* @description starts OSC server
* @description starts OSC server
* @param overrideConfig
* @return {Promise<void>}
@@ -194,20 +194,30 @@ export const startOSCServer = async (overrideConfig = null) => {
/**
* starts integrations
*/
export const startIntegrations = async (config?: { osc: OSCSettings }) => {
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
checkStart(OntimeStartOrder.InitIO);
const { osc } = config ?? DataProvider.getData();
const { osc, http } = config ?? DataProvider.getData();
if (!osc) {
return 'OSC Invalid configuration';
} else {
const { success, message } = oscIntegration.init(osc);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
}
}
if (!http) {
return 'HTTP Invalid configuration';
} else {
const { success, message } = httpIntegration.init(http);
logger.info(LogOrigin.Tx, message);
const { success, message } = oscIntegration.init(osc);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
if (success) {
integrationService.register(httpIntegration);
}
}
};
@@ -72,6 +72,10 @@ export class DataProvider {
return data.osc;
}
static getHttp() {
return data.http;
}
static getAliases() {
return data.aliases;
}
@@ -104,6 +108,11 @@ export class DataProvider {
await this.persist();
}
static async setHttp(newData) {
data.http = { ...newData };
await this.persist();
}
static getRundown() {
return [...data.rundown];
}
@@ -1,8 +1,9 @@
import { LogOrigin, OntimeEvent } from 'ontime-types';
import { EventLoader } from '../classes/event-loader/EventLoader.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js';
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
import { logger } from '../classes/Logger.js';
import { isKeyOfType, isOntimeEvent } from 'ontime-types/src/utils/guards.js';
const whitelistedPayload = {
title: coerceString,
@@ -16,7 +17,8 @@ const whitelistedPayload = {
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceString,
colour: coerceColour,
user0: coerceString,
user1: coerceString,
user2: coerceString,
@@ -29,12 +31,12 @@ const whitelistedPayload = {
user9: coerceString,
};
export function parse(field: string, value: unknown) {
if (!Object.hasOwn(whitelistedPayload, field)) {
throw new Error(`Field ${field} not permitted`);
export function parse(property: string, value: unknown) {
if (!isKeyOfType(property, whitelistedPayload)) {
throw new Error(`Property ${property} not permitted`);
}
const parserFn = whitelistedPayload[field];
return parserFn(value);
const parserFn = whitelistedPayload[property];
return { parsedProperty: property, parsedPayload: parserFn(value) };
}
/**
@@ -49,8 +51,10 @@ export function updateEvent(
newValue: OntimeEvent[typeof propertyName],
) {
const event = EventLoader.getEventWithId(eventId);
if (event) {
if (!isOntimeEvent(event)) {
throw new Error(`Can only update events`);
}
const propertiesToUpdate = { [propertyName]: newValue };
// Handles the special case for duration
@@ -1,25 +1,25 @@
import { OntimeEvent } from 'ontime-types';
import { messageService } from '../services/message-service/MessageService.js';
import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { parse, updateEvent } from './integrationController.config.js';
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
import { event } from '../models/eventsDefinition.js';
export type ChangeOptions = {
eventId: string;
property: string;
value: unknown;
};
//TODO: re-throwing the error does not add any extra information or value
export function dispatchFromAdapter(
type: string,
args: {
payload: unknown;
params?: Array<string>;
},
_source?: 'osc' | 'ws',
) {
const payload = args.payload;
const typeComponents = type.toLowerCase().split('/');
const mainType = typeComponents[0];
const params = args.params || [];
switch (mainType) {
case 'test-ontime': {
@@ -267,21 +267,11 @@ export function dispatchFromAdapter(
return { topic: 'timer', payload: timer };
}
// ontime/change/{eventID}/{propertyName}
// WS: {type: 'change', payload: { eventId, property, value } }
case 'change': {
if (params.length < 2) {
throw new Error('Too few parameters, 3 expected');
}
if (payload === undefined) {
throw new Error('No payload found');
}
const eventID = params[0];
const propertyName = params[1] as keyof OntimeEvent;
if (!isKeyOfType(propertyName, event)) {
throw new Error(`Cannot update unknown event property ${propertyName}`);
}
const parsedPayload = parse(propertyName, payload);
return updateEvent(eventID, propertyName, parsedPayload);
const { eventId, property, value } = payload as ChangeOptions;
const { parsedPayload, parsedProperty } = parse(property, value);
return updateEvent(eventId, parsedProperty, parsedPayload);
}
default: {
+51 -10
View File
@@ -1,4 +1,5 @@
import { Alias, DatabaseModel, GetInfo, LogOrigin, ProjectData } from 'ontime-types';
import { LogOrigin } from 'ontime-types';
import type { Alias, DatabaseModel, GetInfo, HttpSettings, ProjectData } from 'ontime-types';
import { RequestHandler, Request, Response } from 'express';
import fs from 'fs';
@@ -11,11 +12,13 @@ import { PlaybackService } from '../services/PlaybackService.js';
import { eventStore } from '../stores/EventStore.js';
import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js';
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
import { logger } from '../classes/Logger.js';
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
import { deepmerge } from 'ontime-utils';
import { runtimeCacheStore } from '../stores/cachingStore.js';
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
import { integrationService } from '../services/integration-service/IntegrationService.js';
import { Sheet } from '../utils/sheetsAuth.js';
@@ -285,15 +288,42 @@ export const getOSC = async (req, res) => {
res.status(200).send(osc);
};
// Create controller for POST request to '/ontime/osc'
// Returns ACK message
export const postOSC = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
await DataProvider.setOsc(oscSettings);
integrationService.unregister(oscIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { success, message } = oscIntegration.init(oscSettings);
logger.info(LogOrigin.Tx, message);
if (success) {
integrationService.register(oscIntegration);
}
res.send(oscSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
};
export const postOscSubscriptions = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSubscriptions = req.body;
const subscriptions = req.body;
const oscSettings = DataProvider.getOsc();
oscSettings.subscriptions = oscSubscriptions;
oscSettings.subscriptions = subscriptions;
await DataProvider.setOsc(oscSettings);
// TODO: this update could be more granular, checking that relevant data was changed
@@ -306,22 +336,33 @@ export const postOscSubscriptions = async (req, res) => {
}
};
// Create controller for POST request to '/ontime/osc'
// Returns ACK message
export const postOSC = async (req, res) => {
// Create controller for GET request to '/ontime/http'
export const getHTTP = async (_req, res: Response<HttpSettings>) => {
const http = DataProvider.getHttp();
res.status(200).send(http);
};
// Create controller for POST request to '/ontime/http'
export const postHTTP = async (req, res) => {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
await DataProvider.setOsc(oscSettings);
const httpSettings = req.body;
await DataProvider.setHttp(httpSettings);
integrationService.unregister(httpIntegration);
// TODO: this update could be more granular, checking that relevant data was changed
const { message } = oscIntegration.init(oscSettings);
const { success, message } = httpIntegration.init(httpSettings);
logger.info(LogOrigin.Tx, message);
res.send(oscSettings).status(200);
if (success) {
integrationService.register(httpIntegration);
}
res.send(httpSettings).status(200);
} catch (error) {
res.status(400).send({ message: error.toString() });
}
@@ -1,5 +1,9 @@
import { body, check, validationResult } from 'express-validator';
import { validateOscObject, validateOscSubscriptionEntry } from '../utils/parserFunctions.js';
import {
validateHttpSubscriptionObject,
validateOscSubscriptionObject,
validateOscSubscriptionCycle,
} from '../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/views
@@ -82,7 +86,22 @@ export const validateOSC = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.isObject()
.custom((value) => validateOscObject(value)),
.custom((value) => validateOscSubscriptionObject(value)),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.isObject()
.custom((value) => validateHttpSubscriptionObject(value)),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
@@ -96,22 +115,22 @@ export const validateOSC = [
export const validateOscSubscription = [
body('onLoad')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
body('onStart')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
body('onPause')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
body('onStop')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
body('onUpdate')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
body('onFinish')
.isArray()
.custom((value) => validateOscSubscriptionEntry(value)),
.custom((value) => validateOscSubscriptionCycle(value)),
(req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
+11
View File
@@ -61,4 +61,15 @@ export const dbModel: DatabaseModel = {
onFinish: [],
},
},
http: {
enabledOut: false,
subscriptions: {
onLoad: [],
onStart: [],
onPause: [],
onStop: [],
onUpdate: [],
onFinish: [],
},
},
};
+10 -1
View File
@@ -6,6 +6,7 @@ import {
getAliases,
getInfo,
getOSC,
getHTTP,
getSettings,
getUserFields,
getViewSettings,
@@ -19,6 +20,7 @@ import {
postUserFields,
postViewSettings,
previewExcel,
postHTTP,
sheetAuthUrl,
uploadGoogleSheetClientFile,
previewSheet,
@@ -32,12 +34,13 @@ import {
validateAliases,
validateGoogleSheetSettings,
validateOSC,
validateOscSubscription,
validatePatchProjectFile,
validateSettings,
validateSheetPreview,
validateUserFields,
viewValidator,
validateHTTP,
validateOscSubscription,
} from '../controllers/ontimeController.validate.js';
import { projectSanitiser } from '../controllers/projectController.validate.js';
@@ -94,6 +97,12 @@ router.post('/osc', validateOSC, postOSC);
// create route between controller and '/ontime/osc-subscriptions' endpoint
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
// create route between controller and '/ontime/http' endpoint
router.get('/http', getHTTP);
// create route between controller and '/ontime/http' endpoint
router.post('/http', validateHTTP, postHTTP);
// create route between controller and '/ontime/new' endpoint
router.post('/new', projectSanitiser, postNew);
+24 -7
View File
@@ -5,7 +5,7 @@ import { eventStore } from '../stores/EventStore.js';
import { PlaybackService } from './PlaybackService.js';
import { updateRoll } from './rollUtils.js';
import { integrationService } from './integration-service/IntegrationService.js';
import { getCurrent, getExpectedFinish } from './timerUtils.js';
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './timerUtils.js';
import { clock } from './Clock.js';
import { logger } from '../classes/Logger.js';
import type { RestorePoint } from './RestoreService.js';
@@ -18,10 +18,13 @@ type initialLoadingData = {
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
export const timeSkipLimit = 3 * 32;
export class TimerService {
private readonly _interval: NodeJS.Timer;
private _updateInterval: number;
private _lastUpdate: number | null;
private _skipThreshold: number;
playback: Playback;
timer: TimerState;
@@ -40,11 +43,13 @@ export class TimerService {
* @param {object} [timerConfig]
* @param {number} [timerConfig.refresh]
* @param {number} [timerConfig.updateInterval]
* @param {number} [timerConfig.skipThreshold]
*/
constructor(timerConfig: { refresh?: number; updateInterval?: number } = {}) {
constructor(timerConfig: { refresh: number; updateInterval: number; skipThreshold: number }) {
this._clear();
this._interval = setInterval(() => this.update(), timerConfig?.refresh ?? 1000);
this._updateInterval = timerConfig?.updateInterval ?? 1000;
this._interval = setInterval(() => this.update(), timerConfig.refresh);
this._updateInterval = timerConfig.updateInterval;
this._skipThreshold = timerConfig.skipThreshold;
}
/**
@@ -339,7 +344,6 @@ export class TimerService {
this.timer.expectedFinish >= this.timer.startedAt
? this.timer.expectedFinish
: this.timer.expectedFinish + dayInMs,
clock: this.timer.clock,
secondaryTimer: this.timer.secondaryTimer,
secondaryTarget: this.secondaryTarget,
@@ -405,7 +409,19 @@ export class TimerService {
let shouldNotify = false;
if (this.playback === Playback.Roll) {
shouldNotify = true;
this.updateRoll();
if (
skippedOutOfEvent(
previousTime,
this.timer.clock,
this.timer.startedAt,
this.timer.expectedFinish,
this._skipThreshold,
)
) {
PlaybackService.roll();
} else {
this.updateRoll();
}
} else if (this.timer.startedAt !== null) {
// we only update timer if a timer has been started
shouldNotify = true;
@@ -505,4 +521,5 @@ export class TimerService {
}
// calculate at 30fps, refresh at 1fps
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 });
// we consider a skip at 3 lost updates
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000, skipThreshold: 32 * 3 });
@@ -1,7 +1,7 @@
import { dayInMs } from 'ontime-utils';
import { TimerType } from 'ontime-types';
import { getCurrent, getExpectedFinish } from '../timerUtils.js';
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from '../timerUtils.js';
describe('getExpectedFinish()', () => {
it('is null if we havent started', () => {
@@ -354,3 +354,106 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
expect(current).toBe(8);
});
});
describe('skippedOutOfEvent()', () => {
const testSkipLimit = 32;
it('does not consider an event end as a skip', () => {
const startedAt = 1000;
const duration = 1000;
const expectedFinish = startedAt + duration;
const previousTime = expectedFinish - testSkipLimit / 2;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock += testSkipLimit;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
});
it('allows rolling backwards in an event', () => {
const startedAt = 1000;
const duration = 1000;
const expectedFinish = startedAt + duration;
const previousTime = startedAt + testSkipLimit / 2;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock -= testSkipLimit;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
});
it('accounts for crossing midnight', () => {
const startedAt = dayInMs - testSkipLimit;
const expectedFinish = 10;
const previousTime = dayInMs - 1;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock = testSkipLimit - 2;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
});
it('allows rolling backwards in an event across midnight', () => {
const startedAt = dayInMs - testSkipLimit;
const expectedFinish = 10;
const previousTime = startedAt + 1;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock -= testSkipLimit;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
});
it('finds skip forwards out of event', () => {
const startedAt = 1000;
const duration = 1000;
const expectedFinish = startedAt + duration;
const previousTime = expectedFinish - testSkipLimit / 2;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock += testSkipLimit + 1;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
});
it('finds skip backwards out of event', () => {
const startedAt = 1000;
const duration = 1000;
const expectedFinish = startedAt + duration;
const previousTime = startedAt + testSkipLimit / 2;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock -= testSkipLimit + 1;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
});
it('finds skip forwards out of event across midnight', () => {
const startedAt = dayInMs - testSkipLimit;
const expectedFinish = 10;
const previousTime = dayInMs - 3;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock = testSkipLimit - 2;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
});
it('finds skip backwards out of event across midnight', () => {
const startedAt = dayInMs - testSkipLimit;
const expectedFinish = 10;
const previousTime = startedAt + 1;
let clock = previousTime;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
clock -= testSkipLimit + 1;
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
});
});
@@ -0,0 +1,99 @@
import got from 'got';
import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { logger } from '../../classes/Logger.js';
import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
subscriptions: HttpSubscription;
constructor() {
this.subscriptions = dbModel.http.subscriptions;
}
/**
* Initializes httpClient
*/
init(config: HttpSettings) {
const { subscriptions, enabledOut } = config;
if (!enabledOut) {
return {
success: false,
message: 'HTTP output disabled',
};
}
this.initSubscriptions(subscriptions);
try {
return {
success: true,
message: `HTTP integration client ready`,
};
} catch (error) {
return {
success: false,
message: `Failed initialising HTTP integration: ${error}`,
};
}
}
initSubscriptions(subscriptionOptions: HttpSubscription) {
if (validateHttpSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
}
dispatch(action: Action, state?: object) {
if (!action) {
return {
success: false,
message: 'HTTP called with no action',
};
}
// check subscriptions for action
const eventSubscriptions = this.subscriptions?.[action] || [];
eventSubscriptions.forEach((sub) => {
const { enabled, message } = sub;
if (enabled && message) {
const parsedMessage = parseTemplateNested(message, state || {});
try {
const parsedUrl = new URL(parsedMessage);
this.emit(parsedUrl);
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`);
return {
success: false,
message: `${err}`,
};
}
}
});
}
async emit(path: URL) {
try {
await got.get(path, {
retry: { limit: 0 },
});
} catch (err) {
logger.error(LogOrigin.Tx, `HTTP integration: ${err}`);
}
}
shutdown() {}
}
export const httpIntegration = new HttpIntegration();
@@ -1,9 +1,9 @@
import { TimerLifeCycle, OscSubscription } from 'ontime-types';
import { TimerLifeCycle, Subscription } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration {
subscriptions: OscSubscription;
export default interface IIntegration<T> {
subscriptions: Subscription<T>;
init: (config: unknown) => OperationReturn;
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
emit: (...args: unknown[]) => unknown;
@@ -2,17 +2,17 @@ import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { eventStore } from '../../stores/EventStore.js';
class IntegrationService {
private integrations: IIntegration[];
private integrations: IIntegration<unknown>[];
constructor() {
this.integrations = [];
}
register(integrationService: IIntegration) {
register(integrationService: IIntegration<unknown>) {
this.integrations.push(integrationService);
}
unregister(integrationService: IIntegration) {
unregister(integrationService: IIntegration<unknown>) {
this.integrations = this.integrations.filter((int) => int !== integrationService);
}
@@ -1,11 +1,11 @@
import { ArgumentType, Client, Message } from 'node-osc';
import { OSCSettings, OscSubscription } from 'ontime-types';
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { dbModel } from '../../models/dataModel.js';
import { validateOscObject } from '../../utils/parserFunctions.js';
import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js';
type Action = TimerLifeCycleKey | string;
@@ -13,7 +13,7 @@ type Action = TimerLifeCycleKey | string;
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration {
export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
protected oscClient: null | Client;
subscriptions: OscSubscription;
@@ -26,7 +26,15 @@ export class OscIntegration implements IIntegration {
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions } = config;
const { targetIP, portOut, subscriptions, enabledOut } = config;
if (!enabledOut) {
this.oscClient?.close();
return {
success: false,
message: 'OSC output disabled',
};
}
this.initSubscriptions(subscriptions);
@@ -58,7 +66,7 @@ export class OscIntegration implements IIntegration {
}
initSubscriptions(subscriptionOptions: OscSubscription) {
if (validateOscObject(subscriptionOptions)) {
if (validateOscSubscriptionObject(subscriptionOptions)) {
this.subscriptions = { ...subscriptionOptions };
}
}
+17
View File
@@ -64,3 +64,20 @@ export function getCurrent(
}
return startedAt + duration + addedTime + pausedTime - clock;
}
export function skippedOutOfEvent(
previousTime: number,
clock: number,
startedAt: number,
expectedFinish: number,
skipLimit: number,
): boolean {
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
const timeDifference = previousTime - adjustedClock;
const hasSkipped = Math.abs(timeDifference) > skipLimit;
const adjustedExpectedFinish = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
}
@@ -0,0 +1,21 @@
import { coerceColour } from '../coerceType.js';
describe('parses a colour string that is', () => {
it('valid hex', () => {
const color = coerceColour('#000');
expect(color).toBe('#000');
});
it('valid name', () => {
const color = coerceColour('darkgoldenrod');
expect(color).toBe('darkgoldenrod');
});
it('invalid hex', () => {
expect(() => coerceColour('#not a hex color')).toThrowError(Error('Invalid hex colour received'));
});
it('invalid name', () => {
expect(() => coerceColour('bad name')).toThrowError(Error('Invalid colour name received'));
});
it('not a string', () => {
expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received'));
});
});
@@ -1,91 +0,0 @@
import { validateOscObject } from '../parserFunctions.ts';
test('validateOscSubscription()', () => {
it('should return true when given a valid OscSubscription', () => {
const validSubscription = {
onLoad: [{ id: '1', message: 'test', enabled: true }],
onStart: [{ id: '2', message: 'test', enabled: false }],
onPause: [{ id: '3', message: 'test', enabled: true }],
onStop: [{ id: '4', message: 'test', enabled: false }],
onUpdate: [{ id: '5', message: 'test', enabled: true }],
onFinish: [{ id: '6', message: 'test', enabled: false }],
};
const result = validateOscObject(validSubscription);
expect(result).toBe(true);
});
it('should return false when given undefined', () => {
const result = validateOscObject(undefined);
expect(result).toBe(false);
});
it('should return false when given null', () => {
const result = validateOscObject(null);
expect(result).toBe(false);
});
it('should return false when given an empty object', () => {
const result = validateOscObject({});
expect(result).toBe(false);
});
it('should return false when given an empty array', () => {
const result = validateOscObject([]);
expect(result).toBe(false);
});
it('should return false when given an object that is not an OscSubscription', () => {
const invalidObject = { foo: 'bar' };
const result = validateOscObject(invalidObject);
expect(result).toBe(false);
});
it('should return false when given an OscSubscription with a missing property', () => {
const invalidSubscription = {
onLoad: [{ id: '1', message: 'test', enabled: true }],
onStart: [{ id: '2', message: 'test', enabled: false }],
onPause: [{ id: '3', message: 'test', enabled: true }],
// Missing onStop
onUpdate: [{ id: '5', message: 'test', enabled: true }],
onFinish: [{ id: '6', message: 'test', enabled: false }],
};
const result = validateOscObject(invalidSubscription);
expect(result).toBe(false);
});
it('should return false when given an OscSubscription with an invalid property value', () => {
const invalidSubscription = {
onLoad: [{ id: '1', message: 'test', enabled: true }],
onStart: [{ id: '2', message: 'test', enabled: false }],
onPause: [{ id: '3', message: 'test', enabled: true }],
onStop: [{ id: '4', message: 'test', enabled: false }],
onUpdate: [{ id: '5', message: 'test', enabled: true }],
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
};
const result = validateOscObject(invalidSubscription);
expect(result).toBe(false);
});
it('should return true if the message field is empty', () => {
const invalidSubscription = {
onLoad: [{ id: '1', message: 'test', enabled: true }],
onStart: [{ id: '2', message: '', enabled: false }],
onPause: [{ id: '3', message: '', enabled: true }],
onStop: [{ id: '4', message: 'test', enabled: false }],
onUpdate: [{ id: '5', message: 'test', enabled: true }],
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
};
const result = validateOscObject(invalidSubscription);
expect(result).toBe(true);
});
});
@@ -0,0 +1,162 @@
import { HttpSubscription, OscSubscription } from 'ontime-types';
import {
validateOscSubscriptionObject,
validateOscSubscriptionCycle,
validateHttpSubscriptionCycle,
validateHttpSubscriptionObject,
} from '../parserFunctions.js';
describe('validateOscSubscriptionCycle()', () => {
it('should return false when given an OscSubscription with an invalid property value', () => {
const invalidEntry = [{ message: 'test', enabled: 'not a boolean' }];
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionCycle(invalidEntry);
expect(result).toBe(false);
});
});
describe('validateOscSubscriptionObject()', () => {
it('should return true when given a valid OscSubscription', () => {
const validSubscription: OscSubscription = {
onLoad: [{ message: 'test', enabled: true }],
onStart: [{ message: 'test', enabled: false }],
onPause: [{ message: 'test', enabled: true }],
onStop: [{ message: 'test', enabled: false }],
onUpdate: [{ message: 'test', enabled: true }],
onFinish: [{ message: 'test', enabled: false }],
};
const result = validateOscSubscriptionObject(validSubscription);
expect(result).toBe(true);
});
it('should return false when given undefined', () => {
const result = validateOscSubscriptionObject(undefined);
expect(result).toBe(false);
});
it('should return false when given null', () => {
const result = validateOscSubscriptionObject(null);
expect(result).toBe(false);
});
it('should return false when given an empty object', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject({});
expect(result).toBe(false);
});
it('should return false when given an empty array', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject([]);
expect(result).toBe(false);
});
it('should return false when given an object that is not an OscSubscription', () => {
const invalidObject = { foo: 'bar' };
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject(invalidObject);
expect(result).toBe(false);
});
it('should return false when given an OscSubscription with a missing property', () => {
const invalidSubscription = {
onLoad: [{ message: 'test', enabled: true }],
onStart: [{ message: 'test', enabled: false }],
onPause: [{ message: 'test', enabled: true }],
// Missing onStop
onUpdate: [{ message: 'test', enabled: true }],
onFinish: [{ message: 'test', enabled: false }],
};
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject(invalidSubscription);
expect(result).toBe(false);
});
});
describe('validateHttpSubscriptionCycle()', () => {
it('should return false when given an HttpSubscription with an invalid property value', () => {
const invalidBoolean = [{ message: 'http://', enabled: 'not a boolean' }];
const invalidHttp = [{ message: 'test', enabled: true }];
const noFtp = [{ message: 'ftp://test', enabled: true }];
const noEmpty = [{ message: '', enabled: true }];
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
expect(validateHttpSubscriptionCycle(invalidBoolean)).toBe(false);
expect(validateHttpSubscriptionCycle(invalidHttp)).toBe(false);
expect(validateHttpSubscriptionCycle(noFtp)).toBe(false);
expect(validateHttpSubscriptionCycle(noEmpty)).toBe(false);
});
it('should return true when given an HttpSubscription matches definition', () => {
const validHttp = [{ message: 'http://', enabled: true }];
const invalidHttps = [{ message: 'https://', enabled: true }];
expect(validateHttpSubscriptionCycle(validHttp)).toBe(true);
expect(validateHttpSubscriptionCycle(invalidHttps)).toBe(false);
});
});
describe('validateHttpSubscriptionObject()', () => {
it('should return true when given a valid HttpSubscription', () => {
const validSubscription: HttpSubscription = {
onLoad: [{ message: 'http://', enabled: true }],
onStart: [{ message: 'http://', enabled: false }],
onPause: [{ message: 'http://', enabled: true }],
onStop: [{ message: 'http://', enabled: false }],
onUpdate: [{ message: 'http://', enabled: true }],
onFinish: [{ message: 'http://', enabled: false }],
};
const result = validateHttpSubscriptionObject(validSubscription);
expect(result).toBe(true);
});
it('should return false when given undefined', () => {
const result = validateHttpSubscriptionObject(undefined);
expect(result).toBe(false);
});
it('should return false when given null', () => {
const result = validateHttpSubscriptionObject(null);
expect(result).toBe(false);
});
it('should return false when given an empty object', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateOscSubscriptionObject({});
expect(result).toBe(false);
});
it('should return false when given an empty array', () => {
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject([]);
expect(result).toBe(false);
});
it('should return false when given an object that is not an HttpSubscription', () => {
const invalidObject = { foo: 'bar' };
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject(invalidObject);
expect(result).toBe(false);
});
it('should return false when given an HttpSubscription with a missing property', () => {
const invalidSubscription = {
onLoad: [{ message: 'http://', enabled: true }],
onStart: [{ message: 'http://', enabled: false }],
onPause: [{ message: 'http://', enabled: true }],
// Missing onStop
onUpdate: [{ message: 'http://', enabled: true }],
onFinish: [{ message: 'http://', enabled: false }],
};
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
const result = validateHttpSubscriptionObject(invalidSubscription);
expect(result).toBe(false);
});
});
+196 -2
View File
@@ -1,5 +1,8 @@
import { isColourHex } from 'ontime-utils';
//TODO: write tests
/**
* @description Converts a value to a number if possible, throws otherwise
* @description Converts a value to a string if possible, throws otherwise
* @param {unknown} value - Value to be converted to a string.
* @returns {string} - The converted value as a string.
* @throws {Error} Throws an error if the value is null or undefined.
@@ -11,8 +14,9 @@ export function coerceString(value: unknown): string {
return String(value);
}
//TODO: write tests
/**
* @description Converts a value to a number if possible, throws otherwise
* @description Converts a value to a boolean if possible, throws otherwise
* @param {unknown} value - Value to be converted to a boolean.
* @returns {boolean} - The converted value as a boolean.
* @throws {Error} Throws an error if the value is null or undefined.
@@ -21,9 +25,26 @@ export function coerceBoolean(value: unknown): boolean {
if (value == null) {
throw new Error('Invalid value received');
}
if (typeof value === 'string') {
const lowerCaseValue = value.toLocaleLowerCase();
switch (lowerCaseValue) {
case 'true':
case '1':
case 'yes':
return true;
case 'false':
case '0':
case 'no':
case '':
return false;
default:
throw new Error('Invalid value received');
}
}
return Boolean(value);
}
//TODO: write tests
/**
* @description Converts a value to a number if possible, throws otherwise
* @param {unknown} value - Value to be converted to a number.
@@ -40,3 +61,176 @@ export function coerceNumber(value: unknown): number {
}
return parsedValue;
}
/**
* @description Converts a value to a colour if possible, throws otherwise
* @param {unknown} value - Value to be converted to a colour.
* @returns {string} - The converted value as a string.
* @throws {Error} Throws an error if the value is null or undefined.
*/
export function coerceColour(value: unknown): string {
if (typeof value !== 'string') {
throw new Error('Invalid colour value received');
}
const lowerCaseValue = value.toLocaleLowerCase();
if (lowerCaseValue.startsWith('#')) {
if (!isColourHex(lowerCaseValue)) {
throw new Error('Invalid hex colour received');
}
} else if (!(lowerCaseValue in cssColours)) {
throw new Error('Invalid colour name received');
}
return lowerCaseValue;
}
//https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
const cssColours = {
aliceblue: '#f0f8ff',
antiquewhite: '#faebd7',
aqua: '#00ffff',
aquamarine: '#7fffd4',
azure: '#f0ffff',
beige: '#f5f5dc',
bisque: '#ffe4c4',
black: '#000000',
blanchedalmond: '#ffebcd',
blue: '#0000ff',
blueviolet: '#8a2be2',
brown: '#a52a2a',
burlywood: '#deb887',
cadetblue: '#5f9ea0',
chartreuse: '#7fff00',
chocolate: '#d2691e',
coral: '#ff7f50',
cornflowerblue: '#6495ed',
cornsilk: '#fff8dc',
crimson: '#dc143c',
cyan: '#00ffff',
darkblue: '#00008b',
darkcyan: '#008b8b',
darkgoldenrod: '#b8860b',
darkgray: '#a9a9a9',
darkgreen: '#006400',
darkgrey: '#a9a9a9',
darkkhaki: '#bdb76b',
darkmagenta: '#8b008b',
darkolivegreen: '#556b2f',
darkorange: '#ff8c00',
darkorchid: '#9932cc',
darkred: '#8b0000',
darksalmon: '#e9967a',
darkseagreen: '#8fbc8f',
darkslateblue: '#483d8b',
darkslategray: '#2f4f4f',
darkslategrey: '#2f4f4f',
darkturquoise: '#00ced1',
darkviolet: '#9400d3',
deeppink: '#ff1493',
deepskyblue: '#00bfff',
dimgray: '#696969',
dimgrey: '#696969',
dodgerblue: '#1e90ff',
firebrick: '#b22222',
floralwhite: '#fffaf0',
forestgreen: '#228b22',
fuchsia: '#ff00ff',
gainsboro: '#dcdcdc',
ghostwhite: '#f8f8ff',
goldenrod: '#daa520',
gold: '#ffd700',
gray: '#808080',
green: '#008000',
greenyellow: '#adff2f',
grey: '#808080',
honeydew: '#f0fff0',
hotpink: '#ff69b4',
indianred: '#cd5c5c',
indigo: '#4b0082',
ivory: '#fffff0',
khaki: '#f0e68c',
lavenderblush: '#fff0f5',
lavender: '#e6e6fa',
lawngreen: '#7cfc00',
lemonchiffon: '#fffacd',
lightblue: '#add8e6',
lightcoral: '#f08080',
lightcyan: '#e0ffff',
lightgoldenrodyellow: '#fafad2',
lightgray: '#d3d3d3',
lightgreen: '#90ee90',
lightgrey: '#d3d3d3',
lightpink: '#ffb6c1',
lightsalmon: '#ffa07a',
lightseagreen: '#20b2aa',
lightskyblue: '#87cefa',
lightslategray: '#778899',
lightslategrey: '#778899',
lightsteelblue: '#b0c4de',
lightyellow: '#ffffe0',
lime: '#00ff00',
limegreen: '#32cd32',
linen: '#faf0e6',
magenta: '#ff00ff',
maroon: '#800000',
mediumaquamarine: '#66cdaa',
mediumblue: '#0000cd',
mediumorchid: '#ba55d3',
mediumpurple: '#9370db',
mediumseagreen: '#3cb371',
mediumslateblue: '#7b68ee',
mediumspringgreen: '#00fa9a',
mediumturquoise: '#48d1cc',
mediumvioletred: '#c71585',
midnightblue: '#191970',
mintcream: '#f5fffa',
mistyrose: '#ffe4e1',
moccasin: '#ffe4b5',
navajowhite: '#ffdead',
navy: '#000080',
oldlace: '#fdf5e6',
olive: '#808000',
olivedrab: '#6b8e23',
orange: '#ffa500',
orangered: '#ff4500',
orchid: '#da70d6',
palegoldenrod: '#eee8aa',
palegreen: '#98fb98',
paleturquoise: '#afeeee',
palevioletred: '#db7093',
papayawhip: '#ffefd5',
peachpuff: '#ffdab9',
peru: '#cd853f',
pink: '#ffc0cb',
plum: '#dda0dd',
powderblue: '#b0e0e6',
purple: '#800080',
rebeccapurple: '#663399',
red: '#ff0000',
rosybrown: '#bc8f8f',
royalblue: '#4169e1',
saddlebrown: '#8b4513',
salmon: '#fa8072',
sandybrown: '#f4a460',
seagreen: '#2e8b57',
seashell: '#fff5ee',
sienna: '#a0522d',
silver: '#c0c0c0',
skyblue: '#87ceeb',
slateblue: '#6a5acd',
slategray: '#708090',
slategrey: '#708090',
snow: '#fffafa',
springgreen: '#00ff7f',
steelblue: '#4682b4',
tan: '#d2b48c',
teal: '#008080',
thistle: '#d8bfd8',
tomato: '#ff6347',
turquoise: '#40e0d0',
violet: '#ee82ee',
wheat: '#f5deb3',
white: '#ffffff',
whitesmoke: '#f5f5f5',
yellow: '#ffff00',
yellowgreen: '#9acd3',
} as const;
+2 -1
View File
@@ -29,6 +29,7 @@ import {
parseAliases,
parseProject,
parseOsc,
parseHttp,
parseRundown,
parseSettings,
parseUserFields,
@@ -368,7 +369,7 @@ export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
// Import OSC settings if any
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
// Import HTTP settings if any
// returnData.http = parseHttp(jsonData, enforce);
returnData.http = parseHttp(jsonData) ?? dbModel.http;
// Import GoogleSheet settings if any
returnData.googleSheet = parseGoogleSheet(jsonData, true);
+66 -18
View File
@@ -3,14 +3,17 @@ import {
Alias,
GoogleSheet,
OntimeRundown,
HttpSettings,
OSCSettings,
OscSubscription,
OscSubscriptionOptions,
ProjectData,
Settings,
TimerLifeCycle,
UserFields,
ViewSettings,
OscSubscription,
HttpSubscription,
OscSubscriptionOptions,
HttpSubscriptionOptions,
} from 'ontime-types';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
@@ -160,12 +163,12 @@ export const parseViewSettings = (data): ViewSettings => {
};
/**
* Parses and validates subscription entry
* Parses and validates OSC subscription cycle options
* @param data
*/
export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): boolean => {
for (const subscription in data) {
if (typeof data[subscription].message !== 'string' || typeof data[subscription].enabled !== 'boolean') {
export const validateOscSubscriptionCycle = (data: OscSubscriptionOptions[]): boolean => {
for (const subscriptionOption of data) {
if (typeof subscriptionOption.message !== 'string' || typeof subscriptionOption.enabled !== 'boolean') {
return false;
}
}
@@ -173,22 +176,23 @@ export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): bool
};
/**
* Parses and validates subscription object
* Parses and validates OSC subscription object
* @param data
*/
export const validateOscObject = (data: OscSubscription): boolean => {
export const validateOscSubscriptionObject = (data: OscSubscription): boolean => {
if (!data) {
return false;
}
const timerKeys = Object.keys(TimerLifeCycle);
for (const key of timerKeys) {
// must contains all keys and be an array
if (!(key in data) || !Array.isArray(data[key])) {
return false;
}
for (const subscription of data[key]) {
if (typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') {
return false;
}
const isValid = validateOscSubscriptionCycle(data[key]);
if (!isValid) {
return false;
}
}
return true;
@@ -201,8 +205,9 @@ export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
// TODO: this can be improved by only merging known keys
const loadedConfig = data.osc || {};
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
const validatedSubscriptions = validateOscSubscriptionObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions
: dbModel.osc.subscriptions;
@@ -217,20 +222,63 @@ export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
}
};
/**
* Parses and validates HTTP subscription cycle options
* @param data
*/
export const validateHttpSubscriptionCycle = (data: HttpSubscriptionOptions[]): boolean => {
for (const subscriptionOption of data) {
const isHttp = subscriptionOption.message?.startsWith('http://');
if (typeof subscriptionOption.message !== 'string' || !isHttp || typeof subscriptionOption.enabled !== 'boolean') {
return false;
}
}
return true;
};
/**
* Parses and validates HTTP subscription object
* @param data
*/
export const validateHttpSubscriptionObject = (data: HttpSubscription): boolean => {
if (!data) {
return false;
}
const timerKeys = Object.keys(TimerLifeCycle);
// must contains all keys and be an array
for (const key of timerKeys) {
if (!(key in data) || !Array.isArray(data[key])) {
return false;
}
const isValid = validateHttpSubscriptionCycle(data[key]);
if (!isValid) {
return false;
}
}
return true;
};
/**
* Parse Http portion of an entry
* @param {object} data - data object
* @param {boolean} enforce - whether to create a definition if one is missing
* @returns {object} - event object data
*/
export const parseHttp = (data, enforce) => {
const newHttp = {};
export const parseHttp = (data: { http?: Partial<HttpSettings> }): HttpSettings => {
if ('http' in data) {
console.log('Found HTTP definition, importing...');
} else if (enforce) {
/* Not yet */
// TODO: this can be improved by only merging known keys
const loadedConfig = data?.http || {};
const validatedSubscriptions = validateHttpSubscriptionObject(loadedConfig.subscriptions)
? loadedConfig.subscriptions
: dbModel.http.subscriptions;
return {
enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut,
subscriptions: validatedSubscriptions,
};
}
return newHttp;
};
/**
+11
View File
@@ -259,6 +259,17 @@
"targetIP": "127.0.0.1",
"enabled": true
},
"http": {
"enabledOut": false,
"subscriptions": {
"onLoad": [],
"onStart": [],
"onPause": [],
"onStop": [],
"onUpdate": [],
"onFinish": []
}
},
"aliases": [
{
"enabled": true,
+18 -7
View File
@@ -29,8 +29,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "aa42f",
"cue": "1"
"cue": "1",
"id": "aa42f"
},
{
"title": "title 2",
@@ -57,8 +57,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "d71bc",
"cue": "2"
"cue": "2",
"id": "d71bc"
},
{
"title": "title 3",
@@ -85,8 +85,8 @@
"user9": "",
"type": "event",
"revision": 0,
"id": "da5b4",
"cue": "3"
"cue": "3",
"id": "da5b4"
}
],
"project": {
@@ -99,7 +99,7 @@
},
"settings": {
"app": "ontime",
"version": "2.0.0",
"version": "2.21.3",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
@@ -148,5 +148,16 @@
"onUpdate": [],
"onFinish": []
}
},
"http": {
"enabledOut": true,
"subscriptions": {
"onLoad": [],
"onStart": [],
"onPause": [],
"onStop": [],
"onUpdate": [],
"onFinish": []
}
}
}
+11
View File
@@ -468,5 +468,16 @@
],
"onFinish": []
}
},
"http": {
"enabledOut": true,
"subscriptions": {
"onLoad": [],
"onStart": [],
"onPause": [],
"onStop": [],
"onUpdate": [],
"onFinish": []
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "2.16.2",
"version": "2.21.3",
"description": "Time keeping for live events",
"keywords": [
"lighdev",
@@ -6,6 +6,7 @@ import { Settings } from './core/Settings.type.js';
import { UserFields } from './core/UserFields.type.js';
import { ViewSettings } from './core/Views.type.js';
import { GoogleSheet } from '../index.js';
import { HttpSettings } from '../index.js';
export type DatabaseModel = {
rundown: OntimeRundown;
@@ -16,4 +17,5 @@ export type DatabaseModel = {
userFields: UserFields;
googleSheet: GoogleSheet;
osc: OSCSettings;
http: HttpSettings;
};
@@ -0,0 +1,9 @@
import { Subscription } from './Subscription.type.js';
export type HttpSubscriptionOptions = { message: string; enabled: boolean };
export type HttpSubscription = Subscription<HttpSubscriptionOptions>;
export interface HttpSettings {
enabledOut: boolean;
subscriptions: HttpSubscription;
}
@@ -1,7 +1,7 @@
import { TimerLifeCycleKey } from './TimerLifecycle.type.js';
import { Subscription } from './Subscription.type.js';
export type OscSubscriptionOptions = { message: string; enabled: boolean };
export type OscSubscription = { [key in TimerLifeCycleKey]: OscSubscriptionOptions[] };
export type OscSubscription = Subscription<OscSubscriptionOptions>;
export interface OSCSettings {
portIn: number;
@@ -0,0 +1,3 @@
import { TimerLifeCycleKey } from './TimerLifecycle.type.js';
export type Subscription<T> = { [key in TimerLifeCycleKey]: T[] };
+4
View File
@@ -28,10 +28,14 @@ export type { Alias } from './definitions/core/Alias.type.js';
// ---> User Fields
export type { UserFields } from './definitions/core/UserFields.type.js';
// ---> Integration, Subscription
export type { Subscription } from './definitions/core/Subscription.type.js';
// ---> OSC
export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js';
// ---> HTTP
export type { HttpSettings, HttpSubscription, HttpSubscriptionOptions } from './definitions/core/HttpSettings.type.js';
// ---> Google Sheet
export type { GoogleSheet, GoogleSheetState } from './definitions/core/GoogleSheet.type.js';
+1
View File
@@ -15,6 +15,7 @@ export { formatDisplay } from './src/date-utils/formatDisplay.js';
export { formatFromMillis } from './src/date-utils/formatFromMillis.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export { millisToString } from './src/date-utils/millisToString.js';
export { isColourHex } from './src/regex-utils/isColourHex.js';
// time utils
export { dayInMs, mts } from './src/timeConstants.js';
+2 -1
View File
@@ -28,5 +28,6 @@
"prettier": "^3.0.3",
"typescript": "^5.2.2",
"vitest": "^0.30.1"
}
},
"sideEffects": false
}
@@ -0,0 +1,45 @@
import { isColourHex } from './isColourHex';
describe('test isColourHex() function', () => {
it('it validates colour hex strings', () => {
const ts = ['#FFF', '#FFFF', '#FFFFFF', '#FFFFFFFF'];
for (const s of ts) {
expect(isColourHex(s)).toBe(true);
}
});
it('it validates colour hex strings', () => {
const ts = ['#F90', '#1234', '#56789A', '#BCDEF012'];
for (const s of ts) {
expect(isColourHex(s)).toBe(true);
}
});
it('it validates colour hex strings', () => {
const ts = ['#f90', '#1234', '#56789a', '#bcdef012'];
for (const s of ts) {
expect(isColourHex(s)).toBe(true);
}
});
it('it fails digits bigger than F', () => {
const ts = ['#FFG'];
for (const s of ts) {
expect(isColourHex(s)).toBe(false);
}
});
it('it fails incorect amout of digits', () => {
const ts = ['#F', '#FF', '#FFFFF', '#FFFFFFF', '#FFFFFFFFF'];
for (const s of ts) {
expect(isColourHex(s)).toBe(false);
}
});
it('it fails missing #', () => {
const ts = ['FFF', 'FFFF', 'FFFFFF', 'FFFFFFFF'];
for (const s of ts) {
expect(isColourHex(s)).toBe(false);
}
});
});
@@ -0,0 +1,10 @@
/**
* @description Validates a colour hex string
* @param {string} text - colour hex string "#FFF" | "#FFFF" | "#FFFFFF" | "#FFFFFFFF"
* @returns {boolean} string represents time
*/
export const isColourHex = (text: string): boolean => {
const regexS = /^#((?:[a-f\d]{1}){3,4})$/i;
const regexD = /^#((?:[a-f\d]{2}){3,4})$/i;
return regexS.test(text) || regexD.test(text);
};
+101 -21
View File
@@ -276,6 +276,9 @@ importers:
google-auth-library:
specifier: ^9.2.0
version: 9.2.0
got:
specifier: ^14.0.0
version: 14.0.0
lowdb:
specifier: ^5.0.5
version: 5.0.5
@@ -2588,6 +2591,11 @@ packages:
engines: {node: '>=10'}
dev: true
/@sindresorhus/is@6.1.0:
resolution: {integrity: sha512-BuvU07zq3tQ/2SIgBsEuxKYDyDjC0n7Zir52bpHy2xnBbW81+po43aLFPLbeV3HRAheFbGud1qgcqSYfhtHMAg==}
engines: {node: '>=16'}
dev: false
/@svgr/babel-plugin-add-jsx-attribute@6.5.1(@babel/core@7.20.12):
resolution: {integrity: sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==}
engines: {node: '>=10'}
@@ -2720,6 +2728,13 @@ packages:
defer-to-connect: 2.0.1
dev: true
/@szmarczak/http-timer@5.0.1:
resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
engines: {node: '>=14.16'}
dependencies:
defer-to-connect: 2.0.1
dev: false
/@tanstack/eslint-plugin-query@5.8.4(eslint@8.53.0)(typescript@5.2.2):
resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==}
peerDependencies:
@@ -2976,6 +2991,10 @@ packages:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
dev: true
/@types/http-cache-semantics@4.0.4:
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
dev: false
/@types/istanbul-lib-coverage@2.0.4:
resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
dev: true
@@ -3051,10 +3070,6 @@ packages:
resolution: {integrity: sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g==}
dev: true
/@types/node@18.15.11:
resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==}
dev: true
/@types/parse-json@4.0.0:
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
@@ -3997,6 +4012,24 @@ packages:
engines: {node: '>=10.6.0'}
dev: true
/cacheable-lookup@7.0.0:
resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
engines: {node: '>=14.16'}
dev: false
/cacheable-request@10.2.14:
resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==}
engines: {node: '>=14.16'}
dependencies:
'@types/http-cache-semantics': 4.0.4
get-stream: 6.0.1
http-cache-semantics: 4.1.1
keyv: 4.5.4
mimic-response: 4.0.0
normalize-url: 8.0.0
responselike: 3.0.0
dev: false
/cacheable-request@7.0.2:
resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==}
engines: {node: '>=8'}
@@ -4434,7 +4467,6 @@ packages:
engines: {node: '>=10'}
dependencies:
mimic-response: 3.1.0
dev: true
/deep-eql@4.1.3:
resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==}
@@ -4500,7 +4532,6 @@ packages:
/defer-to-connect@2.0.1:
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
engines: {node: '>=10'}
dev: true
/define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
@@ -5453,6 +5484,11 @@ packages:
is-callable: 1.2.7
dev: true
/form-data-encoder@4.0.2:
resolution: {integrity: sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==}
engines: {node: '>= 18'}
dev: false
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
@@ -5635,12 +5671,10 @@ packages:
/get-stream@6.0.1:
resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
engines: {node: '>=10'}
dev: true
/get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
dev: true
/get-symbol-description@1.0.0:
resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
@@ -5777,6 +5811,23 @@ packages:
responselike: 2.0.1
dev: true
/got@14.0.0:
resolution: {integrity: sha512-X01vTgaX9SwaMq5DfImvS+3GMQFFs5HtrrlS9CuzUSzkxAf/tWGEyynuI+Qy7BjciMczZGjyVSmawYbP4eYhYA==}
engines: {node: '>=20'}
dependencies:
'@sindresorhus/is': 6.1.0
'@szmarczak/http-timer': 5.0.1
cacheable-lookup: 7.0.0
cacheable-request: 10.2.14
decompress-response: 6.0.0
form-data-encoder: 4.0.2
get-stream: 8.0.1
http2-wrapper: 2.2.1
lowercase-keys: 3.0.0
p-cancelable: 4.0.1
responselike: 3.0.0
dev: false
/graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
@@ -5863,7 +5914,6 @@ packages:
/http-cache-semantics@4.1.1:
resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==}
dev: true
/http-errors@2.0.0:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
@@ -5895,6 +5945,14 @@ packages:
resolve-alpn: 1.2.1
dev: true
/http2-wrapper@2.2.1:
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
engines: {node: '>=10.19.0'}
dependencies:
quick-lru: 5.1.1
resolve-alpn: 1.2.1
dev: false
/https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
@@ -6405,7 +6463,6 @@ packages:
/json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
dev: true
/json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@@ -6481,7 +6538,6 @@ packages:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
json-buffer: 3.0.1
dev: true
/lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
@@ -6600,6 +6656,11 @@ packages:
engines: {node: '>=8'}
dev: true
/lowercase-keys@3.0.0:
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
dependencies:
@@ -6737,7 +6798,11 @@ packages:
/mimic-response@3.1.0:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
dev: true
/mimic-response@4.0.0:
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
dev: false
/min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
@@ -6936,6 +7001,11 @@ packages:
engines: {node: '>=10'}
dev: true
/normalize-url@8.0.0:
resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==}
engines: {node: '>=14.16'}
dev: false
/npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -7096,6 +7166,11 @@ packages:
engines: {node: '>=8'}
dev: true
/p-cancelable@4.0.1:
resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==}
engines: {node: '>=14.16'}
dev: false
/p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -7367,7 +7442,6 @@ packages:
/quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
dev: true
/random-bytes@1.0.0:
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
@@ -7636,7 +7710,6 @@ packages:
/resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
dev: true
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
@@ -7665,6 +7738,13 @@ packages:
lowercase-keys: 2.0.0
dev: true
/responselike@3.0.0:
resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==}
engines: {node: '>=14.16'}
dependencies:
lowercase-keys: 3.0.0
dev: false
/restore-cursor@4.0.0:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -8680,7 +8760,7 @@ packages:
- terser
dev: true
/vite-node@0.30.1(@types/node@18.15.11)(sass@1.57.1):
/vite-node@0.30.1(@types/node@16.18.23)(sass@1.57.1):
resolution: {integrity: sha512-vTikpU/J7e6LU/8iM3dzBo8ZhEiKZEKRznEMm+mJh95XhWaPrJQraT/QsT2NWmuEf+zgAoMe64PKT7hfZ1Njmg==}
engines: {node: '>=v14.18.0'}
hasBin: true
@@ -8690,7 +8770,7 @@ packages:
mlly: 1.2.0
pathe: 1.1.0
picocolors: 1.0.0
vite: 4.3.1(@types/node@18.15.11)(sass@1.57.1)
vite: 4.3.1(@types/node@16.18.23)(sass@1.57.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -8806,7 +8886,7 @@ packages:
fsevents: 2.3.3
dev: true
/vite@4.3.1(@types/node@18.15.11)(sass@1.57.1):
/vite@4.3.1(@types/node@16.18.23)(sass@1.57.1):
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@@ -8831,7 +8911,7 @@ packages:
terser:
optional: true
dependencies:
'@types/node': 18.15.11
'@types/node': 16.18.23
esbuild: 0.17.5
postcss: 8.4.21
rollup: 3.20.7
@@ -8939,7 +9019,7 @@ packages:
dependencies:
'@types/chai': 4.3.4
'@types/chai-subset': 1.3.3
'@types/node': 18.15.11
'@types/node': 16.18.23
'@vitest/expect': 0.30.1
'@vitest/runner': 0.30.1
'@vitest/snapshot': 0.30.1
@@ -8961,8 +9041,8 @@ packages:
strip-literal: 1.0.1
tinybench: 2.4.0
tinypool: 0.4.0
vite: 4.3.1(@types/node@18.15.11)(sass@1.57.1)
vite-node: 0.30.1(@types/node@18.15.11)(sass@1.57.1)
vite: 4.3.1(@types/node@16.18.23)(sass@1.57.1)
vite-node: 0.30.1(@types/node@16.18.23)(sass@1.57.1)
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less