refactor: remove legacy service

This commit is contained in:
Carlos Valente
2025-01-11 16:06:43 +01:00
committed by Carlos Valente
parent 1f71d4578c
commit 2cc434b0e9
50 changed files with 60 additions and 1851 deletions
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
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: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch };
}
export function usePostHttpSettings() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postHTTP,
onError: (error) => logAxiosError('Error saving HTTP settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useOscSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: postOSC,
onError: (error) => logAxiosError('Error saving OSC settings', error),
onSuccess: (res) => {
ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
});
return { isPending, mutateAsync };
}
-6
View File
@@ -1,6 +0,0 @@
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
};
-3
View File
@@ -1,11 +1,8 @@
import { GetInfo } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
publicDir: '',
};
@@ -1,10 +0,0 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
};
@@ -7,7 +7,6 @@ import AutomationPanel from './panel/automations-panel/AutomationPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
@@ -31,7 +30,6 @@ export default function AppSettings() {
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
@@ -1,189 +0,0 @@
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { HttpSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data, status } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
const onSubmit = async (values: HttpSettings) => {
try {
await mutateAsync(values);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const preventEscape = (event: React.KeyboardEvent) => {
if (isKeyEscape(event)) {
event.preventDefault();
event.stopPropagation();
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
message: '',
enabled: true,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='http-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((integration, index) => {
// @ts-expect-error -- not sure why it is not finding the type, it is ok
const maybeError = errors.subscriptions?.[index]?.message?.message;
return (
<tr key={integration.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.fullWidth}>
<Input
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='http://third-party/vt1/{{timer.current}}'
{...register(`subscriptions.${index}.message`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should start with http://',
},
})}
/>
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,16 +0,0 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -1,42 +0,0 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel({ location }: PanelBaseProps) {
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
return (
<>
<Panel.Header>Integration settings</Panel.Header>
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />
<br />
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
WebSockets are used for Ontime and cannot be configured independently. <br />
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section>
<div ref={oscRef}>
<OscIntegrations />
</div>
<div ref={httpRef}>
<HttpIntegrations />
</div>
</Panel.Section>
</>
);
}
@@ -1,310 +0,0 @@
import { useEffect } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { OSCSettings } from 'ontime-types';
import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data, status } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
return;
}
const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) };
try {
await mutateAsync(parsedValues);
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
};
const handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
address: '',
payload: '',
enabled: true,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Card>
<Panel.SubHeader>
OSC settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='osc-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider />
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>General OSC settings</Panel.Title>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
<Controller
control={control}
name='enabledIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('portIn', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
id='targetIP'
placeholder='127.0.0.1'
width='9rem'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('targetIP', {
required: { value: true, message: 'Required field' },
pattern: {
value: isIPAddress,
message: 'Invalid IP address',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target port'
description='Port number Ontime will send OSC messages to'
error={errors.portOut?.message}
/>
<Input
id='portOut'
placeholder='8888'
width='75px'
size='sm'
textAlign='right'
variant='ontime-filled'
autoComplete='off'
{...register('portOut', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
OSC integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Arguments</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
const maybePayloadError = errors.subscriptions?.[index]?.payload?.message;
return (
<tr key={field.id}>
<td>
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
</td>
<td className={style.autoWidth}>
<Select
size='sm'
variant='ontime'
className={style.fitContents}
{...register(`subscriptions.${index}.cycle`)}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/'
{...register(`subscriptions.${index}.address`, {
required: { value: true, message: 'Required field' },
validate: {
oscStartsWithSlash: (value) =>
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC address only allow ASCII characters',
},
})}
/>
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='{{timer.current}}'
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
},
})}
/>
{maybePayloadError && <Panel.Error>{maybePayloadError}</Panel.Error>}
</td>
<td>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDeleteSubscription(index)}
/>
</td>
</tr>
);
})}
</tbody>
</Panel.Table>
)}
</Panel.Section>
</Panel.Card>
);
}
@@ -1,19 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
{ id: 7, label: 'On Warning', value: 'onWarning' },
{ id: 8, label: 'On Danger', value: 'onDanger' },
];
@@ -23,8 +23,7 @@ type ProjectMergeFormValues = {
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
automation: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
@@ -42,8 +41,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
automation: false,
},
resetOptions: {
keepDirtyValues: true,
@@ -115,12 +113,8 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
<Switch variant='ontime' {...register('automation')} />
Automation Settings
</label>
</Panel.Section>
</Panel.Section>
@@ -46,14 +46,6 @@ const staticOptions = [
],
split: true,
},
{
id: 'integrations',
label: 'Integrations',
secondary: [
{ id: 'integrations__osc', label: 'OSC settings' },
{ id: 'integrations__http', label: 'HTTP settings' },
],
},
{
id: 'automation',
label: 'Automation',
@@ -3,11 +3,40 @@ import { DatabaseModel, AutomationSettings, Automation, NormalisedAutomationBlue
import { dbModel } from '../../models/dataModel.js';
import type { ErrorEmitter } from '../../utils/parser.js';
export function parseAutomationSettings(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): AutomationSettings {
interface LegacyData extends Partial<DatabaseModel> {
http?: unknown;
osc?: {
enabledIn?: boolean;
portIn?: number;
};
}
export function parseAutomationSettings(data: LegacyData, emitError?: ErrorEmitter): AutomationSettings {
/**
* Leaving a path for migrating users to the new automations
* This should be removed after a few releases
*/
if (data.http || data.osc) {
emitError?.('Found legacy integrations');
console.log('Found legacy integrations...');
if (data.osc) {
return {
enabledAutomations: dbModel.automation.enabledAutomations,
enabledOscIn: data.osc?.enabledIn ?? dbModel.automation.enabledOscIn,
oscPortIn: data.osc?.portIn ?? dbModel.automation.oscPortIn,
automations: [],
blueprints: {},
};
} else {
return { ...dbModel.automation };
}
}
if (!data.automation) {
emitError?.('No data found to import');
return { ...dbModel.automation };
}
console.log('Found Automation settings, importing...');
return {
enabledAutomations: data.automation.enabledAutomations ?? dbModel.automation.enabledAutomations,
+1 -3
View File
@@ -18,7 +18,7 @@ import * as projectService from '../../services/project-service/ProjectService.j
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
try {
const { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http, automation } = req.body;
const { rundown, project, settings, viewSettings, urlPresets, customFields, automation } = req.body;
const patchDb: DatabaseModel = {
rundown,
project,
@@ -26,8 +26,6 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
viewSettings,
urlPresets,
customFields,
osc,
http,
automation,
};
@@ -1,31 +0,0 @@
import type { ErrorResponse, HttpSettings } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getHTTP(_req: Request, res: Response<HttpSettings>) {
const http = getDataProvider().getHttp();
res.status(200).send(http);
}
export async function postHTTP(req: Request, res: Response<HttpSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const httpSettings = req.body;
httpIntegration.init(httpSettings);
// we persist the data after init to avoid persisting invalid data
const result = await getDataProvider().setHttp(httpSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -1,9 +0,0 @@
import express from 'express';
import { validateHTTP } from './http.validation.js';
import { getHTTP, postHTTP } from './http.controller.js';
export const router = express.Router();
router.get('/', getHTTP);
router.post('/', validateHTTP, postHTTP);
@@ -1,21 +0,0 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseHttpSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/http
*/
export const validateHTTP = [
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseHttpSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
-4
View File
@@ -4,8 +4,6 @@ import { router as automationsRouter } from './automation/automation.router.js';
import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
import { router as dbRouter } from './db/db.router.js';
import { router as httpRouter } from './http/http.router.js';
import { router as oscRouter } from './osc/osc.router.js';
import { router as projectRouter } from './project/project.router.js';
import { router as rundownRouter } from './rundown/rundown.router.js';
import { router as settingsRouter } from './settings/settings.router.js';
@@ -19,8 +17,6 @@ export const appRouter = express.Router();
appRouter.use('/automations', automationsRouter);
appRouter.use('/custom-fields', customFieldsRouter);
appRouter.use('/db', dbRouter);
appRouter.use('/http', httpRouter);
appRouter.use('/osc', oscRouter);
appRouter.use('/project', projectRouter);
appRouter.use('/rundown', rundownRouter);
appRouter.use('/settings', settingsRouter);
@@ -1,32 +0,0 @@
import type { ErrorResponse, OSCSettings } from 'ontime-types';
import { getErrorMessage } from 'ontime-utils';
import type { Request, Response } from 'express';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
export async function getOSC(_req: Request, res: Response<OSCSettings>) {
const osc = getDataProvider().getOsc();
res.status(200).send(osc);
}
export async function postOSC(req: Request, res: Response<OSCSettings | ErrorResponse>) {
if (failEmptyObjects(req.body, res)) {
return;
}
try {
const oscSettings = req.body;
oscIntegration.init(oscSettings);
// we persist the data after init to avoid persisting invalid data
const result = await getDataProvider().setOsc(oscSettings);
res.send(result).status(200);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -1,8 +0,0 @@
import express from 'express';
import { getOSC, postOSC } from './osc.controller.js';
import { validateOSC } from './osc.validation.js';
export const router = express.Router();
router.get('/', getOSC);
router.post('/', validateOSC, postOSC);
@@ -1,25 +0,0 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
import { sanitiseOscSubscriptions } from '../../utils/parserFunctions.js';
/**
* @description Validates object for POST /ontime/osc
*/
export const validateOSC = [
body('portIn').exists().isPort(),
body('portOut').exists().isPort(),
body('targetIP').exists().isIP(),
body('enabledIn').exists().isBoolean(),
body('enabledOut').exists().isBoolean(),
body('subscriptions')
.exists()
.isArray()
.custom((value) => sanitiseOscSubscriptions(value)),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -34,7 +34,6 @@ export async function getSessionStats(): Promise<SessionStats> {
*/
export async function getInfo(): Promise<GetInfo> {
const { version, serverPort } = getDataProvider().getSettings();
const osc = getDataProvider().getOsc();
// get nif and inject localhost
const ni = getNetworkInterfaces();
@@ -44,7 +43,6 @@ export async function getInfo(): Promise<GetInfo> {
networkInterfaces: ni,
version,
serverPort,
osc,
publicDir: publicDir.root,
};
}
+2 -33
View File
@@ -27,10 +27,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
import { getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
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 './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/RuntimeService.js';
@@ -134,7 +131,7 @@ let expressServer: Server | null = null;
const checkStart = (currentState: OntimeStartOrder) => {
if (step !== currentState) {
step = OntimeStartOrder.Error;
throw new Error('Init order error: initAssets > startServer > startOsc > startIntegrations');
throw new Error('Init order error: initAssets > startServer');
} else {
if (step === 1 || step === 2) {
step = step + 1;
@@ -234,34 +231,6 @@ export const startServer = async (
*/
export const startIntegrations = async () => {
checkStart(OntimeStartOrder.InitIO);
// if a config is not provided, we use the persisted one
const { osc, http } = getDataProvider().getData();
if (http) {
logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...');
try {
httpIntegration.init(http);
integrationService.register(httpIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`);
}
}
if (isOntimeCloud) {
logger.info(LogOrigin.Tx, 'Skipping OSC in Cloud environment...');
return;
}
if (osc) {
logger.info(LogOrigin.Tx, 'Initialising OSC Integration...');
try {
oscIntegration.init(osc);
integrationService.register(oscIntegration);
} catch (error) {
logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed');
}
}
};
/**
@@ -283,8 +252,8 @@ export const shutdown = async (exitCode = 0) => {
expressServer?.close();
runtimeService.shutdown();
integrationService.shutdown();
logger.shutdown();
oscServer.shutdown();
socket.shutdown();
process.exit(exitCode);
};
@@ -3,10 +3,8 @@ import {
OntimeRundown,
ViewSettings,
DatabaseModel,
OSCSettings,
Settings,
CustomFields,
HttpSettings,
URLPreset,
AutomationSettings,
} from 'ontime-types';
@@ -49,14 +47,10 @@ export function getDataProvider() {
setRundown,
getSettings,
setSettings,
getOsc,
getHttp,
getUrlPresets,
setUrlPresets,
getViewSettings,
setViewSettings,
setOsc,
setHttp,
getAutomation,
setAutomation,
getRundown,
@@ -104,14 +98,6 @@ async function setSettings(newData: Settings): ReadonlyPromise<Settings> {
return db.data.settings;
}
function getOsc(): Readonly<OSCSettings> {
return db.data.osc;
}
function getHttp(): Readonly<HttpSettings> {
return db.data.http;
}
function getUrlPresets(): Readonly<URLPreset[]> {
return db.data.urlPresets;
}
@@ -132,19 +118,6 @@ async function setViewSettings(newData: ViewSettings): ReadonlyPromise<ViewSetti
return db.data.viewSettings;
}
async function setOsc(newData: OSCSettings): ReadonlyPromise<OSCSettings> {
db.data.osc = { ...newData };
await persist();
return db.data.osc;
}
async function setHttp(newData: HttpSettings): ReadonlyPromise<HttpSettings> {
db.data.http = { ...newData };
await persist();
return db.data.http;
}
function getAutomation(): Readonly<AutomationSettings> {
return db.data.automation;
}
@@ -165,8 +138,6 @@ async function mergeIntoData(newData: Partial<DatabaseModel>): ReadonlyPromise<D
db.data.settings = mergedData.settings;
db.data.viewSettings = mergedData.viewSettings;
db.data.automation = mergedData.automation;
db.data.osc = mergedData.osc;
db.data.http = mergedData.http;
db.data.urlPresets = mergedData.urlPresets;
db.data.customFields = mergedData.customFields;
db.data.rundown = mergedData.rundown;
@@ -11,8 +11,6 @@ export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseMode
viewSettings = {},
urlPresets = existing.urlPresets,
customFields = existing.customFields,
osc = {},
http = {},
automation = existing.automation,
} = newData;
@@ -35,18 +35,6 @@ describe('safeMerge', () => {
lighting: { type: 'string', label: 'lighting', colour: 'red' },
vfx: { type: 'string', label: 'vfx', colour: 'blue' },
},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: false,
enabledOscIn: false,
@@ -107,38 +95,6 @@ describe('safeMerge', () => {
language: 'pt',
});
});
it('merges the osc key', () => {
const newData = {
osc: {
portIn: 7777,
subscriptions: [
{
id: 'unique',
cycle: 'onStart',
message: 'new message',
enabled: true,
},
],
},
};
//@ts-expect-error -- testing partial merge
const mergedData = safeMerge(existing, newData);
expect(mergedData.osc).toMatchObject({
portIn: 7777,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [
{
id: 'unique',
cycle: 'onStart',
message: 'new message',
enabled: true,
},
],
});
});
it('should merge the urlPresets key when present', () => {
const existingData = {
@@ -167,17 +123,12 @@ describe('safeMerge', () => {
} as ViewSettings,
urlPresets: [],
customFields: {},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
automation: {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8000,
automations: [],
blueprints: {},
},
} as DatabaseModel;
-12
View File
@@ -31,18 +31,6 @@ export const dbModel: DatabaseModel = {
},
urlPresets: [],
customFields: {},
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: true,
enabledOscIn: true,
-12
View File
@@ -450,18 +450,6 @@ export const demoDb: DatabaseModel = {
pathAndParams: 'lower?bg=ff2&text=f00&size=0.6&transition=5',
},
],
osc: {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: true,
enabledOut: false,
subscriptions: [],
},
http: {
enabledOut: false,
subscriptions: [],
},
automation: {
enabledAutomations: false,
enabledOscIn: true,
@@ -1,80 +0,0 @@
import got from 'got';
import { HttpSettings, HttpSubscription, LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { logger } from '../../classes/Logger.js';
/**
* @description Class contains logic towards outgoing HTTP communications
* @class
*/
export class HttpIntegration implements IIntegration<HttpSubscription, HttpSettings> {
subscriptions: HttpSubscription[];
enabled: boolean;
constructor() {
this.subscriptions = [];
this.enabled = false;
}
/**
* Initializes httpClient
*/
init(config: HttpSettings) {
const { subscriptions, enabledOut } = config;
this.initSubscriptions(subscriptions);
this.enabled = enabledOut;
}
initSubscriptions(subscriptions: HttpSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.enabled) {
return;
}
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, message, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !message) {
continue;
}
const parsedMessage = parseTemplateNested(message, state || {});
this.emit(parsedMessage);
}
}
emit(path: string) {
got.get(path, { retry: { limit: 0 } }).catch((err) => {
logger.warning(LogOrigin.Tx, `HTTP Integration: ${err.message}`);
if (err.code === 'ECONNREFUSED') {
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' The server refused the connection`);
return;
}
if (err.code === 'ENOTFOUND') {
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' DNS lookup failed`);
return;
}
if (err.code === 'ETIMEDOUT') {
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' The connection timed out`);
return;
}
logger.warning(LogOrigin.Tx, `HTTP Integration: ${err.code}`);
});
}
shutdown() {
/** shutdown is a no-op here*/
}
}
export const httpIntegration = new HttpIntegration();
@@ -1,11 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
export default interface IIntegration<T, C> {
subscriptions: T[];
init: (config: C) => void;
dispatch: (action: TimerLifeCycleKey, state?: object) => void;
emit: (...args: never[]) => unknown;
shutdown: () => void;
}
@@ -1,37 +0,0 @@
import { LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { eventStore } from '../../stores/EventStore.js';
import { logger } from '../../classes/Logger.js';
class IntegrationService {
private integrations: IIntegration<unknown, unknown>[];
constructor() {
this.integrations = [];
}
register(integrationService: IIntegration<unknown, unknown>) {
this.integrations.push(integrationService);
}
unregister(integrationService: IIntegration<unknown, unknown>) {
this.integrations = this.integrations.filter((int) => int !== integrationService);
}
dispatch(action: TimerLifeCycleKey) {
const state = eventStore.poll();
this.integrations.forEach((integration) => {
integration.dispatch(action, state);
});
}
shutdown() {
logger.info(LogOrigin.Tx, 'Shutdown Integrations');
this.integrations.forEach((integration) => {
integration.shutdown();
});
}
}
export const integrationService = new IntegrationService();
@@ -1,147 +0,0 @@
import { ArgumentType, Client, Message } from 'node-osc';
import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { logger } from '../../classes/Logger.js';
import { OscServer } from '../../adapters/OscAdapter.js';
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
/**
* @description Class contains logic towards outgoing OSC communications
* @class
*/
export class OscIntegration implements IIntegration<OscSubscription, OSCSettings> {
protected oscClient: null | Client;
protected oscServer: OscServer | null = null;
subscriptions: OscSubscription[];
targetIP: MaybeString;
portOut: MaybeNumber;
portIn: MaybeNumber;
enabledOut: boolean;
enabledIn: boolean;
constructor() {
this.oscClient = null;
this.subscriptions = [];
this.targetIP = null;
this.portOut = null;
this.portIn = null;
this.enabledOut = false;
this.enabledIn = false;
}
/**
* Initializes oscClient
*/
init(config: OSCSettings) {
const { targetIP, portOut, subscriptions, enabledOut, enabledIn, portIn } = config;
this.initTX(enabledOut, targetIP, portOut, subscriptions);
this.initRX(enabledIn, portIn);
// return `OSC integration client connected to ${targetIP}:${portOut}`;
}
private initSubscriptions(subscriptions: OscSubscription[]) {
this.subscriptions = subscriptions;
}
dispatch(action: TimerLifeCycleKey, state?: object) {
// noop
if (!this.oscClient || !this.enabledOut) {
return;
}
for (let i = 0; i < this.subscriptions.length; i++) {
const { cycle, address, payload, enabled } = this.subscriptions[i];
if (cycle !== action || !enabled || !address) {
continue;
}
const parsedAddress = parseTemplateNested(address, state || {});
const parsedPayload = payload ? parseTemplateNested(payload, state || {}) : undefined;
const parsedArguments = stringToOSCArgs(parsedPayload);
try {
this.emit(parsedAddress, parsedArguments);
} catch (error) {
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
}
}
}
emit(address: string, args: ArgumentType[]) {
if (!this.oscClient) {
return;
}
//TODO: Look into using bundles
const message = new Message(address);
message.append(args);
this.oscClient.send(message);
}
private initTX(enabledOut: boolean, targetIP: string, portOut: number, subscriptions: OscSubscription[]) {
this.initSubscriptions(subscriptions);
if (!enabledOut) {
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
this.shutdownTX();
return;
}
if (this.oscClient && targetIP === this.targetIP && portOut === this.portOut) {
// nothing changed that would mean we need a new client
return;
}
this.targetIP = targetIP;
this.portOut = portOut;
this.enabledOut = enabledOut;
try {
this.oscClient = new Client(targetIP, portOut);
logger.info(LogOrigin.Tx, `Starting OSC Clint on port: ${portOut}`);
} catch (error) {
this.oscClient = null;
throw new Error(`Failed initialising OSC client: ${error}`);
}
}
private initRX(enabledIn: boolean, portIn: number) {
if (!enabledIn) {
this.shutdownRX();
return;
}
// Start OSC Server
logger.info(LogOrigin.Rx, `Starting OSC Server on port: ${portIn}`);
this.oscServer = new OscServer(portIn);
}
shutdown() {
this.shutdownTX();
this.shutdownRX();
}
private shutdownTX() {
if (this.oscClient) {
logger.info(LogOrigin.Tx, 'Shutting down OSC integration');
this.oscClient?.close();
this.oscClient = null;
}
}
private shutdownRX() {
if (this.oscServer) {
logger.info(LogOrigin.Rx, 'Shutting down OSC integration');
this.oscServer?.shutdown();
this.oscServer = null;
}
}
}
export const oscIntegration = new OscIntegration();
@@ -1,169 +0,0 @@
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
import { parseTemplateNested } from './integrationUtils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
const store = { timer: 10 };
const templateString = '/test/{{timer}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/test/10');
});
it('parses string with a nested variable name', () => {
const store = { timer: { clock: 10 } };
const templateString = '/timer/{{timer.clock}}';
const result = parseTemplateNested(templateString, store);
expect(result).toEqual('/timer/10');
});
it('parses string with multiple variables', () => {
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}';
const expected = `${mockState.test1} should replace ${mockState.test2}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
it('correctly parses a string without templates', () => {
const testString = 'That should replace {test}';
const result = parseTemplateNested(testString, {});
expect(result).toStrictEqual(testString);
});
it('handles scenarios with missing variables', () => {
// by failing to provide a value, we give visibility to
// potential issues in the given string
const mockState = { test1: 'that', test2: 'this' };
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
const result = parseTemplateNested(testString, mockState);
expect(result).toStrictEqual(expected);
});
});
describe('parseNestedTemplate() -> resolveAliasData()', () => {
it('resolves data through callback', () => {
const data = {
not: {
so: {
easy: '3',
},
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{human.easy}}', data, aliases);
expect(easyParse).toBe('testing-3');
});
it('handles a mixed operation', () => {
const data = {
not: {
so: {
easy: '3',
},
},
other: {
value: 42,
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{other.value}} to {{human.easy}}', data, aliases);
expect(easyParse).toBe('42 to testing-3');
});
it('returns given key when not found', () => {
const data = {
not: {
so: {
easy: '3',
},
},
other: {
value: 5,
},
};
const aliases = {
easy: { key: 'not.so.easy', cb: (value: string) => `testing-${value}` },
};
const easyParse = parseTemplateNested('{{other.value}} to {{human.easy}} {{human.not.found}}', data, aliases);
expect(easyParse).toBe('5 to testing-3 {{human.not.found}}');
});
});
describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
it('specific osc requirements', () => {
const data = {
not: {
so: {
easy: 'data with space',
empty: '',
number: 1234,
stringNumber: '1234',
},
},
};
const payloads = [
{
test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }],
},
{
test: '',
expect: [],
},
{
test: ' ',
expect: [],
},
{
test: '""',
expect: [{ type: 'string', value: '' }],
},
{
test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }],
},
{
test: '"string with space and {{not.so.number}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"string with space and {{not.so.stringNumber}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"{{not.so.easy}}" 1',
expect: [
{ type: 'string', value: 'data with space' },
{ type: 'integer', value: 1 },
],
},
{
test: '"{{not.so.empty}}" 1',
expect: [
{ type: 'string', value: '' },
{ type: 'integer', value: 1 },
],
},
{
test: '',
expect: [],
},
];
payloads.forEach((payload) => {
const parsedPayload = parseTemplateNested(payload.test, data);
const parsedArguments = stringToOSCArgs(parsedPayload);
expect(parsedArguments).toStrictEqual(payload.expect);
});
});
});
@@ -1,73 +0,0 @@
import { MaybeNumber } from 'ontime-types';
import { millisToString, removeLeadingZero } from 'ontime-utils';
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
function formatDisplayFromString(value: string, hideZero = false): string {
let valueInNumber: MaybeNumber = null;
if (value !== 'null') {
const parsedValue = Number(value);
if (!Number.isNaN(parsedValue)) {
valueInNumber = parsedValue;
}
}
let formatted = millisToString(valueInNumber, { fallback: hideZero ? '00:00' : '00:00:00' });
if (hideZero) {
formatted = removeLeadingZero(formatted);
}
return formatted;
}
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
const quickAliases: AliasesDefinition = {
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
expectedEnd: {
key: 'timer.expectedFinish',
cb: (value: string) => formatDisplayFromString(value),
},
runningTimer: {
key: 'timer.current',
cb: (value: string) => formatDisplayFromString(value, true),
},
elapsedTime: {
key: 'timer.elapsed',
cb: (value: string) => formatDisplayFromString(value, true),
},
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
};
/**
* Parses a templated string to values in a nested object
*/
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
let parsedTemplate = template;
const matches = Array.from(parsedTemplate.matchAll(placeholderRegex));
for (const match of matches) {
const variableName = match[1];
const variableParts = variableName.split('.');
let value: string | undefined = undefined;
if (variableParts[0] === 'human') {
const lookupKey = variableParts[1];
if (lookupKey in humanReadable) {
const newTemplate = `{{${humanReadable[lookupKey].key}}}`;
const parsed = parseTemplateNested(newTemplate, state, humanReadable);
value = humanReadable[lookupKey].cb(parsed);
} else {
value = undefined;
}
} else {
// iterate through variable parts, and look for the property in the state object
value = variableParts.reduce((obj, key) => obj?.[key], state);
}
if (value !== undefined) {
parsedTemplate = parsedTemplate.replace(match[0], value);
}
}
return parsedTemplate;
}
@@ -28,8 +28,6 @@ import {
setLastLoadedProject,
} from '../app-state-service/AppStateService.js';
import { runtimeService } from '../runtime-service/RuntimeService.js';
import { oscIntegration } from '../integration-service/OscIntegration.js';
import { httpIntegration } from '../integration-service/HttpIntegration.js';
import {
copyCorruptFile,
@@ -176,14 +174,10 @@ export async function loadProjectFile(name: string) {
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
const { rundown, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
}
/**
@@ -250,14 +244,10 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
// apply data model
runtimeService.stop();
const { rundown, customFields, osc, http } = result.data;
const { rundown, customFields } = result.data;
// apply the rundown
await initRundown(rundown, customFields);
// apply integrations
oscIntegration.init(osc);
httpIntegration.init(http);
}
}
@@ -32,7 +32,6 @@ import {
getRundown,
getTimedEvents,
} from '../rundown-service/rundownUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
import { skippedOutOfEvent } from '../timerUtils.js';
@@ -314,7 +314,7 @@ describe('test parser edge cases', () => {
//@ts-expect-error -- we know this is wrong, testing imports outside domain
const { data, errors } = parseDatabaseModel(testData);
expect(data.rundown.length).toBe(1);
expect(errors.length).toBe(7);
expect(errors.length).toBe(5);
});
it('handles incomplete datasets', () => {
@@ -724,8 +724,6 @@ describe('test import of v2 datamodel', () => {
);
// @ts-expect-error -- checking if the field is removed
expect(parsed?.userFields).toBeUndefined();
expect(parsed.osc).toMatchObject({ subscriptions: [] });
expect(parsed.http).toMatchObject({ enabledOut: false, subscriptions: [] });
});
});
@@ -2,12 +2,8 @@ import {
CustomFields,
DatabaseModel,
EndAction,
HttpSettings,
HttpSubscription,
OSCSettings,
OntimeEvent,
OntimeRundown,
OscSubscription,
Settings,
SupportedEvent,
TimeStrategy,
@@ -17,16 +13,12 @@ import {
import {
parseCustomFields,
parseHttp,
parseOsc,
parseProject,
parseRundown,
parseSettings,
parseUrlPresets,
parseViewSettings,
sanitiseCustomFields,
sanitiseHttpSubscriptions,
sanitiseOscSubscriptions,
} from '../parserFunctions.js';
describe('parseRundown()', () => {
@@ -111,66 +103,6 @@ describe('parseViewSettings()', () => {
});
});
describe('parseOsc()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseOsc({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const osc = {
subscriptions: [
{ id: '1', cycle: 'onLoad', address: '/test', payload: 'test', enabled: true }, // OK
{}, // no data
{ id: '2', cycle: 'onStart', payload: 'test', enabled: true }, // no address
],
} as OSCSettings;
const result = parseOsc({ osc }, errorEmitter);
expect(result.subscriptions.length).toEqual(1);
expect(result.subscriptions.at(0)).toMatchObject({
id: '1',
cycle: 'onLoad',
address: '/test',
payload: 'test',
enabled: true,
});
expect(errorEmitter).toHaveBeenCalled();
});
});
describe('parseHttp()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
const result = parseHttp({}, errorEmitter);
expect(result).toBeTypeOf('object');
expect(errorEmitter).toHaveBeenCalledOnce();
});
it('parses data, skipping invalid results', () => {
const errorEmitter = vi.fn();
const http = {
subscriptions: [
{ id: '1', cycle: 'onLoad', message: 'http://', enabled: true }, // OK
{}, // no data
{ id: '2', cycle: 'onStart', enabled: true }, // no message
{ id: '3', cycle: 'onLoad', message: '/test', enabled: true }, // doesnt start with http
],
} as HttpSettings;
const result = parseHttp({ http }, errorEmitter);
expect(result.subscriptions.length).toEqual(1);
expect(result.subscriptions.at(0)).toMatchObject({
id: '1',
cycle: 'onLoad',
message: 'http://',
enabled: true,
});
expect(errorEmitter).toHaveBeenCalled();
});
});
describe('parseUrlPresets()', () => {
it('returns an a base model if nothing is given', () => {
const errorEmitter = vi.fn();
@@ -223,81 +155,6 @@ describe('parseCustomFields()', () => {
});
});
describe('sanitiseOscSubscriptions()', () => {
it('throws if not an array an empty array if not an array', () => {
expect(() => sanitiseOscSubscriptions(undefined)).toThrow();
// @ts-expect-error -- data is external, we check bad types
expect(() => sanitiseOscSubscriptions({})).toThrow();
expect(() => sanitiseOscSubscriptions(null)).toThrow();
});
it('returns an array of valid entries', () => {
const oscSubscriptions: OscSubscription[] = [
{ id: '1', cycle: 'onLoad', address: '/test', payload: 'test', enabled: true },
{ id: '2', cycle: 'onStart', address: '/test', payload: 'test', enabled: false },
{ id: '3', cycle: 'onPause', address: '/test', payload: 'test', enabled: true },
{ id: '4', cycle: 'onStop', address: '/test', payload: 'test', enabled: false },
{ id: '5', cycle: 'onUpdate', address: '/test', payload: 'test', enabled: true },
{ id: '6', cycle: 'onFinish', address: '/test', payload: 'test', enabled: false },
{ id: '7', cycle: 'onWarning', address: '/test', payload: 'test', enabled: false },
{ id: '8', cycle: 'onDanger', address: '/test', payload: 'test', enabled: false },
];
const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions);
expect(sanitationResult).toStrictEqual(oscSubscriptions);
});
it('filters invalid entries', () => {
const oscSubscriptions = [
{ id: '1', cycle: 'onLoad', address: 4, payload: 'test', enabled: true },
{ cycle: 'onLoad', payload: 'test', enabled: true },
{ id: '2', cycle: 'unknown', payload: 'test', enabled: false },
{ id: '3', payload: 'test', enabled: true },
{ id: '4', cycle: 'onStop', enabled: false },
{ id: '5', cycle: 'onUpdate', payload: 'test' },
{ id: '6', cycle: 'onFinish', payload: 'test', enabled: 'true' },
] as OscSubscription[];
const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions);
expect(sanitationResult.length).toBe(0);
});
});
describe('sanitiseHttpSubscriptions()', () => {
it('throws if the data is unexpected', () => {
expect(() => sanitiseHttpSubscriptions(undefined)).toThrow();
// @ts-expect-error -- data is external, we check bad types
expect(() => sanitiseHttpSubscriptions({})).toThrow();
expect(() => sanitiseHttpSubscriptions(null)).toThrow();
});
it('returns an array of valid entries', () => {
const httpSubscription: HttpSubscription[] = [
{ id: '1', cycle: 'onLoad', message: 'http://test', enabled: true },
{ id: '2', cycle: 'onStart', message: 'http://test', enabled: false },
{ id: '3', cycle: 'onPause', message: 'http://test', enabled: true },
{ id: '4', cycle: 'onStop', message: 'http://test', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'http://test', enabled: true },
{ id: '6', cycle: 'onFinish', message: 'http://test', enabled: false },
{ id: '7', cycle: 'onWarning', message: 'http://test', enabled: false },
{ id: '8', cycle: 'onDanger', message: 'http://test', enabled: false },
];
const sanitationResult = sanitiseHttpSubscriptions(httpSubscription);
expect(sanitationResult).toStrictEqual(httpSubscription);
});
it('filters invalid entries', () => {
const httpSubscription = [
{ cycle: 'onLoad', message: 'http://test', enabled: true },
{ id: '2', cycle: 'unknown', message: 'http://test', enabled: false },
{ id: '3', message: 'http://test', enabled: true },
{ id: '4', cycle: 'onStop', enabled: false },
{ id: '5', cycle: 'onUpdate', message: 'http://test' },
{ id: '6', cycle: 'onFinish', message: 'ftp://test', enabled: 'true' },
];
const sanitationResult = sanitiseHttpSubscriptions(httpSubscription as HttpSubscription[]);
expect(sanitationResult.length).toBe(0);
});
});
describe('sanitiseCustomFields()', () => {
it('returns an empty array if not an array', () => {
expect(sanitiseCustomFields({})).toEqual({});
+1 -11
View File
@@ -27,15 +27,7 @@ import { logger } from '../classes/Logger.js';
import { event as eventDef } from '../models/eventsDefinition.js';
import { makeString } from './parserUtils.js';
import {
parseHttp,
parseOsc,
parseProject,
parseRundown,
parseSettings,
parseUrlPresets,
parseViewSettings,
} from './parserFunctions.js';
import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
import { parseExcelDate } from './time.js';
export type ErrorEmitter = (message: string) => void;
@@ -334,8 +326,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: Da
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
customFields,
osc: parseOsc(jsonData, makeEmitError('OSC')),
http: parseHttp(jsonData, makeEmitError('HTTP')),
automation: parseAutomationSettings(jsonData),
};
+1 -102
View File
@@ -2,25 +2,20 @@ import {
CustomField,
CustomFields,
DatabaseModel,
HttpSettings,
HttpSubscription,
OSCSettings,
OntimeBlock,
OntimeDelay,
OntimeEvent,
OntimeRundown,
OscSubscription,
ProjectData,
Settings,
TimerType,
URLPreset,
ViewSettings,
isOntimeBlock,
isOntimeCycle,
isOntimeDelay,
isOntimeEvent,
} from 'ontime-types';
import { customFieldLabelToKey, generateId, getErrorMessage, isAlphanumericWithSpace } from 'ontime-utils';
import { customFieldLabelToKey, generateId, isAlphanumericWithSpace } from 'ontime-utils';
import { dbModel } from '../models/dataModel.js';
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
@@ -165,102 +160,6 @@ export function parseViewSettings(data: Partial<DatabaseModel>, emitError?: Erro
};
}
/**
* Sanitises an OSC Subscriptions array
*/
export function sanitiseOscSubscriptions(subscriptions?: OscSubscription[]): OscSubscription[] {
if (!Array.isArray(subscriptions)) {
throw new Error('ERROR: invalid OSC subscriptions');
}
return subscriptions.filter(
({ id, cycle, address, payload, enabled }) =>
typeof id === 'string' &&
isOntimeCycle(cycle) &&
typeof address === 'string' &&
typeof payload === 'string' &&
typeof enabled === 'boolean',
);
}
/**
* Parse osc portion of an entry
*/
export function parseOsc(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): OSCSettings {
if (!data.osc) {
emitError?.('No data found to import');
return { ...dbModel.osc };
}
console.log('Found OSC settings, importing...');
let newSubscriptions: OscSubscription[] = [];
try {
newSubscriptions = sanitiseOscSubscriptions(data.osc.subscriptions);
} catch (error) {
emitError?.(getErrorMessage(error));
}
if (newSubscriptions.length !== data.osc.subscriptions.length) {
emitError?.('Skipped invalid subscriptions');
}
return {
portIn: data.osc.portIn ?? dbModel.osc.portIn,
portOut: data.osc.portOut ?? dbModel.osc.portOut,
targetIP: data.osc.targetIP ?? dbModel.osc.targetIP,
enabledIn: data.osc.enabledIn ?? dbModel.osc.enabledIn,
enabledOut: data.osc.enabledOut ?? dbModel.osc.enabledOut,
subscriptions: newSubscriptions,
};
}
/**
* Sanitises an HTTP Subscriptions array
*/
export function sanitiseHttpSubscriptions(subscriptions?: HttpSubscription[]): HttpSubscription[] {
if (!Array.isArray(subscriptions)) {
throw new Error('ERROR: invalid HTTP subscriptions');
}
return subscriptions.filter(
({ id, cycle, message, enabled }) =>
typeof id === 'string' &&
isOntimeCycle(cycle) &&
typeof message === 'string' &&
message.startsWith('http://') &&
typeof enabled === 'boolean',
);
}
/**
* Parse Http portion of an entry
*/
export function parseHttp(data: Partial<DatabaseModel>, emitError?: ErrorEmitter): HttpSettings {
if (!data.http) {
emitError?.('No data found to import');
return { ...dbModel.http };
}
console.log('Found HTTP settings, importing...');
let newSubscriptions: HttpSubscription[] = [];
try {
newSubscriptions = sanitiseHttpSubscriptions(data.http.subscriptions);
} catch (error) {
emitError?.(getErrorMessage(error));
}
if (newSubscriptions.length !== data.http?.subscriptions.length) {
emitError?.('Skipped invalid subscriptions');
}
return {
enabledOut: data.http.enabledOut ?? dbModel.http.enabledOut,
subscriptions: newSubscriptions,
};
}
/**
* Parse URL preset portion of an entry
*/
+6 -11
View File
@@ -436,17 +436,12 @@
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
}
],
"osc": {
"portIn": 8888,
"portOut": 9999,
"targetIP": "127.0.0.1",
"enabledIn": true,
"enabledOut": true,
"subscriptions": []
},
"http": {
"enabledOut": true,
"subscriptions": []
"automation": {
"enabledAutomations": false,
"enabledOscIn": true,
"oscPortIn": 8888,
"automations": [],
"blueprints": {}
},
"customFields": {
"song": {
+6 -11
View File
@@ -436,17 +436,12 @@
"pathAndParams": "lower?bg=ff2&text=f00&size=0.6&transition=5"
}
],
"osc": {
"portIn": 8888,
"portOut": 9999,
"targetIP": "127.0.0.1",
"enabledIn": true,
"enabledOut": true,
"subscriptions": []
},
"http": {
"enabledOut": true,
"subscriptions": []
"automation": {
"enabledAutomations": false,
"enabledOscIn": true,
"oscPortIn": 8888,
"automations": [],
"blueprints": {}
},
"customFields": {
"song": {
@@ -1,4 +1,3 @@
import type { OSCSettings } from '../../definitions/core/OscSettings.type.js';
import type { OntimeRundown } from '../../definitions/core/Rundown.type.js';
import type { Playback } from '../../definitions/runtime/Playback.type.js';
import type { MaybeString } from '../../utils/utils.type.js';
@@ -22,7 +21,6 @@ export interface GetInfo {
networkInterfaces: NetworkInterface[];
version: string;
serverPort: number;
osc: OSCSettings;
publicDir: string;
}
@@ -1,9 +1,7 @@
import type {
AutomationSettings,
CustomFields,
HttpSettings,
OntimeRundown,
OSCSettings,
ProjectData,
Settings,
URLPreset,
@@ -17,7 +15,5 @@ export type DatabaseModel = {
viewSettings: ViewSettings;
urlPresets: URLPreset[];
customFields: CustomFields;
osc: OSCSettings;
http: HttpSettings;
automation: AutomationSettings;
};
@@ -1,8 +0,0 @@
import type { TimerLifeCycleKey } from './TimerLifecycle.type.js';
export type HttpSubscription = { id: string; cycle: TimerLifeCycleKey; message: string; enabled: boolean };
export interface HttpSettings {
enabledOut: boolean;
subscriptions: HttpSubscription[];
}
@@ -1,18 +0,0 @@
import type { TimerLifeCycleKey } from './TimerLifecycle.type.js';
export type OscSubscription = {
id: string;
cycle: TimerLifeCycleKey;
address: string;
payload: string; // TODO: we should be using arguments to keep in line with protocol language
enabled: boolean;
};
export interface OSCSettings {
portIn: number;
portOut: number;
targetIP: string;
enabledIn: boolean;
enabledOut: boolean;
subscriptions: OscSubscription[];
}
-4
View File
@@ -52,10 +52,6 @@ export type {
EventCustomFields,
} from './definitions/core/CustomFields.type.js';
// ---> Integration, Subscription
export type { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js';
export type { HttpSettings, HttpSubscription } from './definitions/core/HttpSettings.type.js';
// SERVER RESPONSES
export type {
AuthenticationStatus,