mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 05:57:59 +00:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf8c8d4b2e | |||
| 9e63261b35 | |||
| b1838a5e9a | |||
| 9d7b5568e4 | |||
| 657aed1244 | |||
| 13c49f3652 | |||
| 86b4ddadb5 | |||
| e9f806b88a | |||
| 4f672670ed | |||
| 8742b790f3 | |||
| d41cd78f2e | |||
| 715600a514 | |||
| b99cf5f255 | |||
| 9a9a0eb7cf | |||
| 4bf2422ebc | |||
| add769fe1e | |||
| 3af4c64ff3 | |||
| f405e65c55 | |||
| 0e32b3a3af | |||
| c2fa115946 | |||
| edb48a2fc7 | |||
| 6f7846bbf7 | |||
| 48ebd31c54 | |||
| 6d1a911ab8 | |||
| ee1753ef29 | |||
| 147e5f5a27 | |||
| a02b3faacc | |||
| a3b21072eb | |||
| 2076080c5f | |||
| 8b3abe9d61 | |||
| c2039866b4 | |||
| 73e4a15718 | |||
| c4359af2a0 | |||
| f75f45b19a | |||
| fd2eee32aa | |||
| a53d354b16 | |||
| ec55236eb2 | |||
| 3895b37572 | |||
| d8626ff324 | |||
| 6c3870c15b | |||
| 4fadb23b07 | |||
| 199ed1617f | |||
| 2d618e0657 | |||
| f928c1dc95 | |||
| 8d526c7d31 | |||
| 686c6108bf | |||
| ea3d33ca93 | |||
| 6bf94ff645 | |||
| 930cd71ffd |
@@ -81,6 +81,9 @@ From the project root, run the following commands
|
||||
|
||||
The build distribution assets will be at `.apps/electron/dist`
|
||||
|
||||
Note: The MacOS build will only work in CI, locally it will fail due to notarisation issues.
|
||||
Use the `turbo dist-mac:local` command to build a MacOS distribution locally.
|
||||
|
||||
## DOCKER
|
||||
|
||||
Ontime provides a docker-compose file to aid with building and running docker images.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.0.4",
|
||||
"version": "3.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -70,13 +70,13 @@
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-jest": "^27.6.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jest": "^28.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-react": "^7.32.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
@@ -84,7 +84,7 @@
|
||||
"jsdom": "^21.1.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"ontime-utils": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.2.11",
|
||||
|
||||
@@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { fileName });
|
||||
async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { filename });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,7 +123,7 @@ export async function renameProject(filename: string, newFilename: string): Prom
|
||||
const url = `${dbPath}/${filename}/rename`;
|
||||
const decodedUrl = decodeURIComponent(url);
|
||||
const res = await axios.put(decodedUrl, {
|
||||
newFilename,
|
||||
filename: newFilename,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
+28
-21
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: $progress-bar-size;
|
||||
border-radius: $progress-bar-br;
|
||||
background-color: var(--timer-progress-bg-override, $viewer-card-bg-color);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
@@ -18,31 +14,42 @@ $progress-bar-br: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: var(--progress-bar-br, $progress-bar-br);
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
background-color: black;
|
||||
inset: 0;
|
||||
margin: -1px;
|
||||
margin-left: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.multiprogress-bar__indicator-bar {
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
opacity: 0.8;
|
||||
transition: 1s linear;
|
||||
transition-property: width;
|
||||
right: 0;
|
||||
|
||||
.multiprogress-bar--ignore-css-override & {
|
||||
background-color: $ui-black;
|
||||
}
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-normal {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-warning {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.multiprogress-bar__bg-danger {
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
right: 0;
|
||||
}
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
+33
-13
@@ -13,31 +13,51 @@ interface MultiPartProgressBar {
|
||||
danger?: MaybeNumber;
|
||||
dangerColor: string;
|
||||
hidden?: boolean;
|
||||
ignoreCssOverride?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiPartProgressBar(props: MultiPartProgressBar) {
|
||||
const { now, complete, normalColor, warning, warningColor, danger, dangerColor, hidden, className = '' } = props;
|
||||
const {
|
||||
now,
|
||||
complete,
|
||||
normalColor,
|
||||
warning,
|
||||
warningColor,
|
||||
danger,
|
||||
dangerColor,
|
||||
hidden,
|
||||
ignoreCssOverride,
|
||||
className = '',
|
||||
} = props;
|
||||
|
||||
const percentRemaining = complete === 0 ? 0 : 100 - clamp(100 - (Math.max(now ?? 0, 0) * 100) / complete, 0, 100);
|
||||
|
||||
const dangerWidth = danger ? clamp((danger / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100, 0, 100) : 0;
|
||||
const warningWidth = warning ? clamp((warning / complete) * 100 - dangerWidth, 0, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${className}`}>
|
||||
<div
|
||||
className={`multiprogress-bar ${hidden ? 'multiprogress-bar--hidden' : ''} ${
|
||||
ignoreCssOverride ? 'multiprogress-bar--ignore-css-override' : ''
|
||||
} ${className}`}
|
||||
>
|
||||
{now !== null && (
|
||||
<>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} />
|
||||
<div className='multiprogress-bar__bg'>
|
||||
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
|
||||
<div
|
||||
className='multiprogress-bar__bg-warning'
|
||||
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
|
||||
/>
|
||||
<div
|
||||
className='multiprogress-bar__bg-danger'
|
||||
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
|
||||
/>
|
||||
</div>
|
||||
<div className='multiprogress-bar__indicator'>
|
||||
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItemOption,
|
||||
MenuList,
|
||||
MenuOptionGroup,
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
@@ -34,6 +47,10 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
return <MultiOption paramField={paramField} />;
|
||||
}
|
||||
|
||||
if (type === 'boolean') {
|
||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||
|
||||
@@ -70,3 +87,47 @@ export default function ParamInput(props: EditFormInputProps) {
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
}
|
||||
|
||||
function MultiOption(props: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
|
||||
const optionFromParams = (searchParams.get(id) ?? '').toLocaleLowerCase();
|
||||
const defaultOptionValue = optionFromParams || defaultValue?.toLocaleLowerCase() || '';
|
||||
|
||||
const [paramState, setParamState] = useState<string>(defaultOptionValue);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState.split('_')}
|
||||
onChange={(value) => {
|
||||
setParamState(typeof value === 'object' ? value.filter((v) => v !== '').join('_') : value);
|
||||
}}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
|
||||
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils';
|
||||
|
||||
import { ParamField } from './types';
|
||||
import { type ParamField } from './types';
|
||||
|
||||
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
|
||||
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
|
||||
return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
|
||||
}, additionalOptions ?? {});
|
||||
return customFieldOptions;
|
||||
};
|
||||
@@ -30,6 +28,14 @@ const hideTimerSeconds: ParamField = {
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
const showLeadingZeros: ParamField = {
|
||||
id: 'showLeadingZeros',
|
||||
title: 'Show leading zeros in timer',
|
||||
description: 'Whether to show leading zeros in the running timer',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
};
|
||||
|
||||
export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
getTimeOption(timeFormat),
|
||||
{
|
||||
@@ -103,10 +109,12 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
|
||||
];
|
||||
|
||||
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
|
||||
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
|
||||
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
|
||||
return [
|
||||
getTimeOption(timeFormat),
|
||||
hideTimerSeconds,
|
||||
showLeadingZeros,
|
||||
{
|
||||
id: 'hideClock',
|
||||
title: 'Hide Time Now',
|
||||
@@ -114,6 +122,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Main text',
|
||||
description: 'Select the data source for the main text',
|
||||
type: 'option',
|
||||
values: mainOptions,
|
||||
defaultValue: 'Title',
|
||||
},
|
||||
{
|
||||
id: 'secondary-src',
|
||||
title: 'Secondary text',
|
||||
@@ -230,13 +246,6 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
||||
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',
|
||||
@@ -453,8 +462,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
|
||||
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
|
||||
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
|
||||
|
||||
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => {
|
||||
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` };
|
||||
const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
|
||||
return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
|
||||
}, {});
|
||||
|
||||
return [
|
||||
@@ -486,9 +495,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
|
||||
id: 'subscribe',
|
||||
title: 'Highlight Field',
|
||||
description: 'Choose a custom field to highlight',
|
||||
type: 'option',
|
||||
type: 'multi-option',
|
||||
values: customFieldSelect,
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
id: 'shouldEdit',
|
||||
|
||||
@@ -9,8 +9,15 @@ type OptionsField = {
|
||||
values: Record<string, string>;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: Record<string, { value: string; label: string; colour: 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);
|
||||
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
// first set extends TimerState
|
||||
export type ViewExtendedTimer = {
|
||||
@@ -8,12 +8,11 @@ export type ViewExtendedTimer = {
|
||||
elapsed: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
finishedAt: MaybeNumber;
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber;
|
||||
startedAt: MaybeNumber;
|
||||
|
||||
clock: number;
|
||||
timeDanger: MaybeNumber;
|
||||
timeWarning: MaybeNumber;
|
||||
timerType: TimerType;
|
||||
};
|
||||
|
||||
@@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) {
|
||||
type AppModeStore = {
|
||||
mode: AppMode;
|
||||
setMode: (mode: AppMode) => void;
|
||||
cursor: string | null;
|
||||
setCursor: (cursor: string | null) => void;
|
||||
};
|
||||
|
||||
export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
mode: getModeFromSession(),
|
||||
cursor: null,
|
||||
setMode: (mode: AppMode) => {
|
||||
persistModeToSession(mode);
|
||||
|
||||
@@ -33,5 +30,4 @@ export const useAppMode = create<AppModeStore>()((set) => ({
|
||||
return { mode };
|
||||
});
|
||||
},
|
||||
setCursor: (cursor: string | null) => set({ cursor }),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import isEqual from 'react-fast-compare';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
|
||||
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
@@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
|
||||
@@ -3,6 +3,6 @@ export function isMacOS() {
|
||||
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
|
||||
|
||||
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
|
||||
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
|
||||
|
||||
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
|
||||
export const startsWithSlash = /^\//;
|
||||
export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isNotEmpty = /\S/;
|
||||
|
||||
@@ -17,4 +17,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
}
|
||||
}
|
||||
|
||||
export default {};
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
|
||||
<div className={style.column}>
|
||||
<Panel.Description>Label</Panel.Description>
|
||||
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
|
||||
{errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
|
||||
<Input
|
||||
{...register('label', {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -8,7 +9,7 @@ import { generateId } from 'ontime-utils';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||
import { isKeyEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../PanelUtils';
|
||||
|
||||
import { cycles } from './integrationUtils';
|
||||
@@ -40,6 +41,13 @@ export default function OscIntegrations() {
|
||||
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' });
|
||||
@@ -221,7 +229,7 @@ export default function OscIntegrations() {
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.halfWidth}>Address</th>
|
||||
<th className={style.halfWidth}>Payload</th>
|
||||
<th className={style.halfWidth}>Arguments</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -277,7 +285,7 @@ export default function OscIntegrations() {
|
||||
{...register(`subscriptions.${index}.payload`, {
|
||||
validate: {
|
||||
oscStringIsAscii: (value) =>
|
||||
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
|
||||
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
|
||||
const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
|
||||
try {
|
||||
setError(null);
|
||||
const filename = values.title?.trim();
|
||||
|
||||
const filename = values.title ?? 'untitled';
|
||||
|
||||
await createProject({
|
||||
...values,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.uploadSection,
|
||||
.successSection {
|
||||
.finishSection {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
padding: 3rem 1rem;
|
||||
@@ -15,12 +15,18 @@
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.successSection {
|
||||
color: $green-500;
|
||||
.finishSection {
|
||||
font-size: 1.5rem;
|
||||
text-align: center;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.error {
|
||||
color: $red-500;
|
||||
}
|
||||
.success {
|
||||
color: $green-500;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
|
||||
@@ -76,8 +76,13 @@ export default function SourcesPanel() {
|
||||
setAuthenticationStatus(result.authenticated);
|
||||
setSheetId(result.sheetId);
|
||||
if (result.authenticated === 'authenticated' && result.sheetId) {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
try {
|
||||
const names = await getWorksheetNames(result.sheetId);
|
||||
setWorksheets(names);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError(`Error getting worksheets: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
setImportFlow('gsheet');
|
||||
@@ -129,11 +134,20 @@ export default function SourcesPanel() {
|
||||
await exportRundown(sheetId, importMap);
|
||||
};
|
||||
|
||||
const resetFlow = () => {
|
||||
setImportFlow('none');
|
||||
setRundown(null);
|
||||
setHasFile('none');
|
||||
setWorksheets(null);
|
||||
setCustomFields(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const isExcelFlow = importFlow === 'excel';
|
||||
const isGSheetFlow = importFlow === 'gsheet';
|
||||
const isAuthenticated = authenticationStatus === 'authenticated';
|
||||
const showInput = importFlow === 'none';
|
||||
const showSuccess = importFlow === 'finished';
|
||||
const showCompleted = importFlow === 'finished';
|
||||
const showAuth = isGSheetFlow && !isAuthenticated;
|
||||
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
|
||||
const showReview = rundown !== null && customFields !== null;
|
||||
@@ -184,10 +198,18 @@ export default function SourcesPanel() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showSuccess && (
|
||||
<div className={style.successSection}>
|
||||
<span>Import successful</span>
|
||||
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}>
|
||||
{showCompleted && (
|
||||
<div className={style.finishSection}>
|
||||
{error ? (
|
||||
<span key='finish__error' className={style.error}>
|
||||
Import failed
|
||||
</span>
|
||||
) : (
|
||||
<span key='finish__success' className={style.success}>
|
||||
Import successful
|
||||
</span>
|
||||
)}
|
||||
<Button variant='ontime-filled' size='sm' onClick={resetFlow}>
|
||||
Return
|
||||
</Button>
|
||||
</div>
|
||||
@@ -195,6 +217,7 @@ export default function SourcesPanel() {
|
||||
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
|
||||
{showImportMap && !showReview && (
|
||||
<ImportMapForm
|
||||
hasErrors={Boolean(error)}
|
||||
isSpreadsheet={isExcelFlow}
|
||||
onCancel={cancelImportMap}
|
||||
onSubmitExport={handleSubmitExport}
|
||||
|
||||
+4
-3
@@ -15,14 +15,15 @@ import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportM
|
||||
import style from '../SourcesPanel.module.scss';
|
||||
|
||||
interface ImportMapFormProps {
|
||||
isSpreadsheet?: boolean;
|
||||
hasErrors: boolean;
|
||||
isSpreadsheet: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmitExport: (importMap: ImportMap) => Promise<void>;
|
||||
onSubmitImport: (importMap: ImportMap) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
|
||||
const namedImportMap = getPersistedOptions();
|
||||
const { revoke } = useGoogleSheet();
|
||||
const {
|
||||
@@ -78,7 +79,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
|
||||
const isLoading = Boolean(loading);
|
||||
const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
|
||||
const canSubmitGSheet = !isLoading;
|
||||
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' id='import-map'>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
.extraRow {
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 2rem;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@@ -29,33 +29,36 @@ export function AuxTimer() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.extraRow}>
|
||||
<AuxTimerInput />
|
||||
<TapButton onClick={toggleDirection} aspect='tight'>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
|
||||
</TapButton>
|
||||
<label className={style.label}>
|
||||
Auxiliary Timer
|
||||
<div className={style.controls}>
|
||||
<AuxTimerInput />
|
||||
<TapButton onClick={toggleDirection} aspect='tight'>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
|
||||
</TapButton>
|
||||
|
||||
<TapButton
|
||||
onClick={start}
|
||||
theme={Playback.Play}
|
||||
active={playback === SimplePlayback.Start}
|
||||
disabled={!userCan.start}
|
||||
>
|
||||
<IoPlay data-testid='aux-timer-start' />
|
||||
</TapButton>
|
||||
<TapButton
|
||||
onClick={pause}
|
||||
theme={Playback.Pause}
|
||||
active={playback === SimplePlayback.Pause}
|
||||
disabled={!userCan.pause}
|
||||
>
|
||||
<IoPause data-testid='aux-timer-pause' />
|
||||
</TapButton>
|
||||
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
|
||||
<IoStop data-testid='aux-timer-stop' />
|
||||
</TapButton>
|
||||
</div>
|
||||
<TapButton
|
||||
onClick={start}
|
||||
theme={Playback.Play}
|
||||
active={playback === SimplePlayback.Start}
|
||||
disabled={!userCan.start}
|
||||
>
|
||||
<IoPlay data-testid='aux-timer-start' />
|
||||
</TapButton>
|
||||
<TapButton
|
||||
onClick={pause}
|
||||
theme={Playback.Pause}
|
||||
active={playback === SimplePlayback.Pause}
|
||||
disabled={!userCan.pause}
|
||||
>
|
||||
<IoPause data-testid='aux-timer-pause' />
|
||||
</TapButton>
|
||||
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
|
||||
<IoStop data-testid='aux-timer-stop' />
|
||||
</TapButton>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import { Playback } from 'ontime-types';
|
||||
import { Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
|
||||
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
@@ -43,8 +43,8 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
|
||||
const finish = millisToString(expectedFinish);
|
||||
|
||||
const isRolling = playback === Playback.Roll;
|
||||
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
|
||||
const isOvertime = timer.current !== null && timer.current < 0;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
@@ -16,12 +16,9 @@ interface TimerDisplayProps {
|
||||
export default function TimerDisplay(props: TimerDisplayProps) {
|
||||
const { time } = props;
|
||||
|
||||
if (time == null) {
|
||||
return <div className={style.timer}>{timerPlaceholder}</div>;
|
||||
}
|
||||
|
||||
const isNegative = time < 0;
|
||||
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
|
||||
const isNegative = (time ?? 0) < 0;
|
||||
const display =
|
||||
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
|
||||
const classes = cx([style.timer, isNegative ? style.finished : null]);
|
||||
|
||||
return <div className={classes}>{display}</div>;
|
||||
|
||||
@@ -36,7 +36,6 @@ $table-header-font-size: calc(1rem - 3px);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
font-weight: 400;
|
||||
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
|
||||
@@ -25,7 +25,7 @@ interface CuesheetProps {
|
||||
}
|
||||
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
|
||||
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
|
||||
@@ -112,7 +112,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
)}
|
||||
<div ref={tableContainerRef} className={style.cuesheetContainer}>
|
||||
<table className={style.cuesheet}>
|
||||
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} />
|
||||
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
|
||||
<tbody>
|
||||
{rowModel.rows.map((row) => {
|
||||
const key = row.original.id;
|
||||
@@ -165,6 +165,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
skip={row.original.skip}
|
||||
colour={row.original.colour}
|
||||
showIndexColumn={showIndexColumn}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function CuesheetProgress() {
|
||||
danger={timeDanger}
|
||||
dangerColor={data!.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
ignoreCssOverride
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Tooltip } from '@chakra-ui/react';
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
@@ -13,7 +12,6 @@ import { flexRender, HeaderGroup } from '@tanstack/react-table';
|
||||
import { OntimeRundownEntry } from 'ontime-types';
|
||||
|
||||
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
|
||||
import { SortableCell } from './SortableCell';
|
||||
|
||||
@@ -22,10 +20,11 @@ import style from '../Cuesheet.module.scss';
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||
saveColumnOrder: (fromId: string, toId: string) => void;
|
||||
showIndexColumn: boolean;
|
||||
}
|
||||
|
||||
export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
const { headerGroups, saveColumnOrder } = props;
|
||||
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
@@ -61,11 +60,7 @@ export default function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
return (
|
||||
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
|
||||
<tr key={headerGroup.id}>
|
||||
<th className={style.indexColumn}>
|
||||
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
|
||||
#
|
||||
</Tooltip>
|
||||
</th>
|
||||
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
|
||||
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const width = header.getSize();
|
||||
|
||||
@@ -8,6 +8,7 @@ const pastOpacity = '0.2';
|
||||
|
||||
interface EventRowProps {
|
||||
eventIndex: number;
|
||||
showIndexColumn: boolean;
|
||||
isPast?: boolean;
|
||||
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
|
||||
skip?: boolean;
|
||||
@@ -15,7 +16,7 @@ interface EventRowProps {
|
||||
}
|
||||
|
||||
function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
|
||||
const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
|
||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -56,7 +57,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
||||
ref={selectedRef ?? ownRef}
|
||||
>
|
||||
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
|
||||
{eventIndex}
|
||||
{showIndexColumn && eventIndex}
|
||||
</td>
|
||||
{isVisible ? children : null}
|
||||
</tr>
|
||||
|
||||
@@ -24,11 +24,13 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
|
||||
const {
|
||||
followSelected,
|
||||
showIndexColumn,
|
||||
toggleFollow,
|
||||
showPrevious,
|
||||
togglePreviousVisibility,
|
||||
showDelayBlock,
|
||||
showDelayedTimes,
|
||||
toggleIndexColumn,
|
||||
toggleDelayedTimes,
|
||||
toggleDelayVisibility,
|
||||
} = useCuesheetSettings();
|
||||
@@ -63,6 +65,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
|
||||
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
|
||||
Show past events
|
||||
</label>
|
||||
<label className={style.option}>
|
||||
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
|
||||
Show Event Order
|
||||
</label>
|
||||
</div>
|
||||
<div className={style.sectionTitle}>Delay Flow</div>
|
||||
<div className={style.options}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
|
||||
|
||||
interface CuesheetSettings {
|
||||
showSettings: boolean;
|
||||
showIndexColumn: boolean;
|
||||
followSelected: boolean;
|
||||
showPrevious: boolean;
|
||||
showDelayBlock: boolean;
|
||||
@@ -12,6 +13,7 @@ interface CuesheetSettings {
|
||||
toggleSettings: (newValue?: boolean) => void;
|
||||
toggleFollow: (newValue?: boolean) => void;
|
||||
togglePreviousVisibility: (newValue?: boolean) => void;
|
||||
toggleIndexColumn: (newValue?: boolean) => void;
|
||||
toggleDelayVisibility: (newValue?: boolean) => void;
|
||||
toggleDelayedTimes: (newValue?: boolean) => void;
|
||||
}
|
||||
@@ -27,11 +29,13 @@ enum CuesheetKeys {
|
||||
Follow = 'ontime-cuesheet-follow-selected',
|
||||
DelayVisibility = 'ontime-cuesheet-show-delay',
|
||||
PreviousVisibility = 'ontime-cuesheet-show-previous',
|
||||
ColumnIndex = 'ontime-cuesheet-show-index-column',
|
||||
DelayedTimes = 'ontime-cuesheet-show-delayed',
|
||||
}
|
||||
|
||||
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
showSettings: false,
|
||||
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
|
||||
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
|
||||
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
|
||||
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
|
||||
@@ -44,6 +48,12 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
|
||||
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
|
||||
return { followSelected };
|
||||
}),
|
||||
toggleIndexColumn: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showIndexColumn = toggle(state.showIndexColumn, newValue);
|
||||
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
|
||||
return { showIndexColumn };
|
||||
}),
|
||||
togglePreviousVisibility: (newValue?: boolean) =>
|
||||
set((state) => {
|
||||
const showPrevious = toggle(state.showPrevious, newValue);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { CustomField, CustomFields, isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { isOntimeEvent, OntimeEvent, SupportedEvent } from 'ontime-types';
|
||||
import { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
|
||||
|
||||
import Empty from '../../common/components/state/Empty';
|
||||
@@ -27,11 +27,9 @@ import style from './Operator.module.scss';
|
||||
|
||||
const selectedOffset = 50;
|
||||
|
||||
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
|
||||
type TitleFields = Pick<OntimeEvent, 'title'>;
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string };
|
||||
export type PartialEdit = EditEvent & {
|
||||
field: keyof CustomFields;
|
||||
};
|
||||
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
|
||||
|
||||
export default function Operator() {
|
||||
const { data, status } = useRundown();
|
||||
@@ -45,7 +43,7 @@ export default function Operator() {
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false);
|
||||
const [editEvent, setEditEvent] = useState<PartialEdit | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<EditEvent | null>(null);
|
||||
|
||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -102,16 +100,9 @@ export default function Operator() {
|
||||
debouncedHandleScroll();
|
||||
};
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(event: EditEvent) => {
|
||||
const field = searchParams.get('subscribe') as keyof CustomField | null;
|
||||
|
||||
if (field) {
|
||||
setEditEvent({ ...event, field });
|
||||
}
|
||||
},
|
||||
[searchParams],
|
||||
);
|
||||
const handleEdit = useCallback((event: EditEvent) => {
|
||||
setEditEvent({ ...event });
|
||||
}, []);
|
||||
|
||||
const missingData = !data || !customFields || !projectData;
|
||||
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
|
||||
@@ -122,8 +113,13 @@ export default function Operator() {
|
||||
|
||||
// get fields which the user subscribed to
|
||||
const shouldEdit = searchParams.get('shouldEdit');
|
||||
const subscribe = searchParams.get('subscribe') as keyof CustomFields;
|
||||
const canEdit = shouldEdit && subscribe;
|
||||
|
||||
const subscriptions = (searchParams.get('subscribe') ?? '')
|
||||
.toLocaleLowerCase()
|
||||
.split('_')
|
||||
.filter((value) => Object.hasOwn(customFields, value));
|
||||
|
||||
const canEdit = shouldEdit && subscriptions;
|
||||
|
||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||
const secondary = searchParams.get('secondary');
|
||||
@@ -171,9 +167,14 @@ export default function Operator() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mainField = main ? entry?.[main] || entry.title : entry.title;
|
||||
const mainField = main ? getPropertyValue(entry, main) ?? '' : entry.title;
|
||||
const secondaryField = getPropertyValue(entry, secondary) ?? '';
|
||||
const subscribedData = entry.custom[subscribe];
|
||||
const subscribedData = subscriptions
|
||||
? subscriptions.map((id) => {
|
||||
const { label, colour } = customFields[id];
|
||||
return { id, label, colour, value: entry.custom[id] };
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<OperatorEvent
|
||||
@@ -189,7 +190,6 @@ export default function Operator() {
|
||||
delay={entry.delay}
|
||||
isSelected={isSelected}
|
||||
subscribed={subscribedData}
|
||||
subscribeLabel={subscribe}
|
||||
isPast={isPast}
|
||||
selectedRef={isSelected ? selectedRef : undefined}
|
||||
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||
|
||||
@@ -1,48 +1,69 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Button, Textarea } from '@chakra-ui/react';
|
||||
import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||
import type { PartialEdit } from '../Operator';
|
||||
import type { EditEvent } from '../Operator';
|
||||
|
||||
import style from './EditModal.module.scss';
|
||||
|
||||
interface EditModalProps {
|
||||
event: PartialEdit;
|
||||
event: EditEvent;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function EditModal(props: EditModalProps) {
|
||||
const { event, onClose } = props;
|
||||
|
||||
const { updateCustomField } = useEventAction();
|
||||
const { updateEvent } = useEventAction();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!inputRef.current) return;
|
||||
setLoading(true);
|
||||
const newValue = inputRef.current?.value;
|
||||
if (newValue === undefined) {
|
||||
return;
|
||||
|
||||
const patchObject: Partial<OntimeEvent> = { id: event.id };
|
||||
|
||||
inputRef.current.forEach((element) => {
|
||||
if (element.dataset.field && element.defaultValue != element.value) {
|
||||
if (patchObject.custom) {
|
||||
patchObject.custom[element.dataset.field] = element.value;
|
||||
} else {
|
||||
Object.assign(patchObject, { custom: { [element.dataset.field]: element.value } });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (patchObject.custom) {
|
||||
await updateEvent(patchObject);
|
||||
}
|
||||
|
||||
await updateCustomField(event.id, event.field, newValue);
|
||||
setLoading(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const fieldLabel = event?.fieldLabel ?? event.field;
|
||||
|
||||
return (
|
||||
<div className={style.editModal}>
|
||||
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div>
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
variant='ontime-filled'
|
||||
placeholder={`Add value for ${fieldLabel} field`}
|
||||
defaultValue={event.fieldValue}
|
||||
isDisabled={loading}
|
||||
resize='none'
|
||||
/>
|
||||
<div>{`Editing fields in cue ${event.cue}`}</div>
|
||||
{event.subscriptions.map((field) => {
|
||||
return (
|
||||
<div key={field.label}>
|
||||
<label>{field.label}</label>
|
||||
<Textarea
|
||||
ref={(element) => {
|
||||
if (element) inputRef.current.push(element);
|
||||
}}
|
||||
variant='ontime-filled'
|
||||
placeholder={`Add value for ${field.label} field`}
|
||||
defaultValue={field.value}
|
||||
data-field={field.id}
|
||||
isDisabled={loading}
|
||||
resize='none'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className={style.buttonRow}>
|
||||
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
|
||||
Cancel
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: 0.5rem;
|
||||
grid-template-areas:
|
||||
"binder main schedule"
|
||||
"binder secondary running"
|
||||
"binder fields fields";
|
||||
'binder main schedule'
|
||||
'binder secondary running'
|
||||
'binder fields fields';
|
||||
|
||||
&.subscribed {
|
||||
background-color: $gray-1250;
|
||||
@@ -93,16 +93,25 @@
|
||||
font-weight: 400;
|
||||
color: $ui-black;
|
||||
margin: 0.25rem 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.field {
|
||||
font-weight: 600;
|
||||
padding: 0 0.25rem;
|
||||
background-color: var(--operator-highlight-override, $orange-600);
|
||||
margin-right: 0.5rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.noColour {
|
||||
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
|
||||
outline-offset: -0.15rem;
|
||||
padding-right: 0.3rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: $orange-500;
|
||||
color: var(--operator-highlight-override, $ui-white);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useTimer } from '../../../common/hooks/useSocket';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import ClockTime from '../../viewers/common/clock-time/ClockTime';
|
||||
import RunningTime from '../../viewers/common/running-time/RunningTime';
|
||||
import type { EditEvent } from '../Operator';
|
||||
import type { EditEvent, Subscribed } from '../Operator';
|
||||
|
||||
import style from './OperatorEvent.module.scss';
|
||||
|
||||
@@ -21,8 +21,7 @@ interface OperatorEventProps {
|
||||
duration: number;
|
||||
delay?: number;
|
||||
isSelected: boolean;
|
||||
subscribed?: string;
|
||||
subscribeLabel: string;
|
||||
subscribed: Subscribed | null;
|
||||
isPast: boolean;
|
||||
selectedRef?: RefObject<HTMLDivElement>;
|
||||
onLongPress: (event: EditEvent) => void;
|
||||
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
delay,
|
||||
isSelected,
|
||||
subscribed,
|
||||
subscribeLabel: subscribedAlias,
|
||||
isPast,
|
||||
selectedRef,
|
||||
onLongPress,
|
||||
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
const handleLongPress = (event?: SyntheticEvent) => {
|
||||
// we dont have an event out of useLongPress
|
||||
event?.preventDefault();
|
||||
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' });
|
||||
if (subscribed) {
|
||||
onLongPress({ id, cue, subscriptions: subscribed });
|
||||
}
|
||||
};
|
||||
|
||||
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
|
||||
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
|
||||
</span>
|
||||
|
||||
<div className={style.fields}>
|
||||
{subscribed && (
|
||||
<>
|
||||
<span className={style.field}>{subscribedAlias}</span>
|
||||
<span className={style.value}>{subscribed}</span>
|
||||
</>
|
||||
)}
|
||||
{subscribed &&
|
||||
subscribed
|
||||
.filter((field) => field.value)
|
||||
.map((field) => {
|
||||
const fieldClasses = cx([style.field, !field.colour ? style.noColour : null]);
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<span className={fieldClasses} style={{ backgroundColor: field.colour }}>
|
||||
{field.label}
|
||||
</span>
|
||||
<span className={style.value} style={{ color: field.colour }}>
|
||||
{field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,5 +105,5 @@
|
||||
}
|
||||
|
||||
.progressOverride {
|
||||
border-radius: 0;
|
||||
--progress-bar-br: 0;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function StatusBarProgress(props: StatusBarProgressProps) {
|
||||
danger={timeDanger}
|
||||
dangerColor={viewSettings.dangerColor}
|
||||
className={styles.progressOverride}
|
||||
ignoreCssOverride
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
import QuickAddBlock from './quick-add-block/QuickAddBlock';
|
||||
import RundownEmpty from './RundownEmpty';
|
||||
import { useEventSelection } from './useEventSelection';
|
||||
|
||||
import style from './Rundown.module.scss';
|
||||
|
||||
@@ -33,7 +34,9 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const { entryCopyId, setEntryCopyId } = useEntryCopy();
|
||||
|
||||
// cursor
|
||||
const { cursor, mode: appMode, setCursor } = useAppMode();
|
||||
const { mode: appMode } = useAppMode();
|
||||
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
|
||||
|
||||
const cursorRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
|
||||
@@ -44,34 +47,42 @@ export default function Rundown({ data }: RundownProps) {
|
||||
const deleteAtCursor = useCallback(
|
||||
(cursor: string | null) => {
|
||||
if (!cursor) return;
|
||||
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
|
||||
const { entry, index } = getPreviousNormal(rundown, order, cursor);
|
||||
deleteEvent([cursor]);
|
||||
setCursor(previous);
|
||||
if (entry && index !== null) {
|
||||
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
|
||||
}
|
||||
},
|
||||
[deleteEvent, order, rundown, setCursor],
|
||||
[rundown, order, deleteEvent, setSelectedEvents],
|
||||
);
|
||||
|
||||
const insertAtCursor = useCallback(
|
||||
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => {
|
||||
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor;
|
||||
|
||||
if (adjustedCursor === null) {
|
||||
const insertCopyAtId = useCallback(
|
||||
(atId: string | null, copyId: string | null, above = false) => {
|
||||
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId;
|
||||
if (copyId === null) {
|
||||
// we cant clone without selection
|
||||
if (type === 'clone') {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cloneEntry = rundown[copyId];
|
||||
if (cloneEntry?.type === SupportedEvent.Event) {
|
||||
//if we don't have a cursor add the new event on top
|
||||
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined);
|
||||
addEvent(newEvent);
|
||||
}
|
||||
},
|
||||
[addEvent, order, rundown],
|
||||
);
|
||||
|
||||
const insertAtId = useCallback(
|
||||
(type: SupportedEvent, id: string | null, above = false) => {
|
||||
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id;
|
||||
if (adjustedCursor === null) {
|
||||
// the only thing to do is adding an event at top
|
||||
addEvent({ type });
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'clone') {
|
||||
const cursorEvent = rundown[adjustedCursor];
|
||||
if (cursorEvent?.type === SupportedEvent.Event) {
|
||||
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
|
||||
addEvent(newEvent);
|
||||
}
|
||||
} else if (type === SupportedEvent.Event) {
|
||||
if (type === SupportedEvent.Event) {
|
||||
const newEvent = {
|
||||
type: SupportedEvent.Event,
|
||||
};
|
||||
@@ -92,23 +103,26 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (order.length < 1) {
|
||||
return;
|
||||
}
|
||||
let newCursor: string | undefined;
|
||||
let newCursor: string | null;
|
||||
let newIndex: number | null;
|
||||
if (cursor === null) {
|
||||
// there is no cursor, we select the first or last depending on direction if it exists
|
||||
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id;
|
||||
newCursor =
|
||||
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
|
||||
newIndex = direction === 'up' ? order.length : 0;
|
||||
} else {
|
||||
// otherwise we select the next or previous
|
||||
newCursor =
|
||||
direction === 'up'
|
||||
? getPreviousNormal(rundown, order, cursor).entry?.id
|
||||
: getNextNormal(rundown, order, cursor).entry?.id;
|
||||
const selected =
|
||||
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
|
||||
newCursor = selected.entry?.id ?? null;
|
||||
newIndex = selected.index;
|
||||
}
|
||||
|
||||
if (newCursor) {
|
||||
setCursor(newCursor);
|
||||
if (newCursor && newIndex !== null) {
|
||||
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
|
||||
}
|
||||
},
|
||||
[order, rundown, setCursor],
|
||||
[order, rundown, setSelectedEvents],
|
||||
);
|
||||
|
||||
const moveEntry = useCallback(
|
||||
@@ -134,22 +148,22 @@ export default function Rundown({ data }: RundownProps) {
|
||||
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
|
||||
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
|
||||
|
||||
['Escape', () => setCursor(null), { preventDefault: true }],
|
||||
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
|
||||
|
||||
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
|
||||
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
||||
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
|
||||
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
||||
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
|
||||
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
|
||||
|
||||
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
|
||||
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
|
||||
|
||||
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
|
||||
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }],
|
||||
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }],
|
||||
['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
|
||||
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
|
||||
|
||||
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
|
||||
]);
|
||||
@@ -165,8 +179,9 @@ export default function Rundown({ data }: RundownProps) {
|
||||
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
|
||||
return;
|
||||
}
|
||||
setCursor(featureData.selectedEventId);
|
||||
}, [appMode, featureData.selectedEventId, setCursor]);
|
||||
const index = order.findIndex((id) => id === featureData.selectedEventId);
|
||||
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
|
||||
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
@@ -185,7 +200,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
};
|
||||
|
||||
if (statefulEntries.length < 1) {
|
||||
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
|
||||
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
|
||||
}
|
||||
|
||||
let previousStart: MaybeNumber = null;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent
|
||||
|
||||
import { useEventAction } from '../../common/hooks/useEventAction';
|
||||
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
|
||||
import { useAppMode } from '../../common/stores/appModeStore';
|
||||
import { useEmitLog } from '../../common/stores/logger';
|
||||
import { cloneEvent } from '../../common/utils/eventsManager';
|
||||
|
||||
@@ -56,22 +55,15 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
} = props;
|
||||
const { emitError } = useEmitLog();
|
||||
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
|
||||
const cursor = useAppMode((state) => state.cursor);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
|
||||
|
||||
const removeOpenEvent = useCallback(() => {
|
||||
unselect(data.id);
|
||||
// clear cursor if we are deleting the event that is currently selected
|
||||
if (cursor === data.id) {
|
||||
setCursor(null);
|
||||
}
|
||||
}, [unselect, data.id, cursor, setCursor]);
|
||||
}, [unselect, data.id]);
|
||||
|
||||
const clearMultiSelection = useCallback(() => {
|
||||
clearSelectedEvents();
|
||||
setCursor(null);
|
||||
}, [clearSelectedEvents, setCursor]);
|
||||
}, [clearSelectedEvents]);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
|
||||
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import type { EventItemActions } from '../RundownEntry';
|
||||
import { useEventIdSwapping } from '../useEventIdSwapping';
|
||||
@@ -88,7 +87,6 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
} = props;
|
||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||
const { selectedEvents, setSelectedEvents } = useEventSelection();
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
@@ -231,7 +229,6 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
const index = eventIndex - 1;
|
||||
const editMode = getSelectionMode(event);
|
||||
setSelectedEvents({ id: eventId, index, selectMode: editMode });
|
||||
setCursor(eventId);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,7 +40,7 @@ function EventEditorEmpty() {
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -56,7 +56,7 @@ function EventEditorEmpty() {
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
@@ -74,7 +74,7 @@ function EventEditorEmpty() {
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>⌫</Kbd>
|
||||
<Kbd>Backspace</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
@@ -91,7 +91,7 @@ function EventEditorEmpty() {
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
@@ -109,7 +109,7 @@ function EventEditorEmpty() {
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>B</Kbd>
|
||||
</td>
|
||||
@@ -127,7 +127,7 @@ function EventEditorEmpty() {
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useEventSelection } from '../useEventSelection';
|
||||
|
||||
export default function RundownMenu() {
|
||||
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
|
||||
const setCursor = useAppMode((state) => state.setCursor);
|
||||
const appMode = useAppMode((state) => state.mode);
|
||||
const { deleteAllEvents } = useEventAction();
|
||||
|
||||
@@ -27,9 +26,8 @@ export default function RundownMenu() {
|
||||
const deleteAll = useCallback(() => {
|
||||
deleteAllEvents();
|
||||
clearSelectedEvents();
|
||||
setCursor(null);
|
||||
onClose();
|
||||
}, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]);
|
||||
}, [clearSelectedEvents, deleteAllEvents, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MouseEvent } from 'react';
|
||||
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
|
||||
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { RUNDOWN } from '../../common/api/constants';
|
||||
@@ -10,7 +10,8 @@ export type SelectionMode = 'shift' | 'click' | 'ctrl';
|
||||
|
||||
interface EventSelectionStore {
|
||||
selectedEvents: Set<string>;
|
||||
anchoredIndex: number | null;
|
||||
anchoredIndex: MaybeNumber;
|
||||
cursor: MaybeString;
|
||||
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
|
||||
clearSelectedEvents: () => void;
|
||||
clearMultiSelect: () => void;
|
||||
@@ -20,13 +21,14 @@ interface EventSelectionStore {
|
||||
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
selectedEvents: new Set(),
|
||||
anchoredIndex: null,
|
||||
cursor: null,
|
||||
setSelectedEvents: (selectionArgs) => {
|
||||
const { id, index, selectMode } = selectionArgs;
|
||||
const { selectedEvents, anchoredIndex } = get();
|
||||
|
||||
// on click, we replace selection with event
|
||||
if (selectMode === 'click') {
|
||||
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
|
||||
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id });
|
||||
}
|
||||
|
||||
// on ctrl + click, we toggle the selection of that event
|
||||
@@ -39,6 +41,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
return set({
|
||||
selectedEvents: selectedEvents.add(id),
|
||||
anchoredIndex: index,
|
||||
cursor: id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,7 +86,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null }),
|
||||
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
|
||||
clearMultiSelect: () => {
|
||||
const { selectedEvents } = get();
|
||||
const [firstSelected] = selectedEvents;
|
||||
|
||||
@@ -82,8 +82,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
||||
...timer,
|
||||
clock,
|
||||
timerType: eventNow?.timerType ?? null,
|
||||
timeWarning: eventNow?.timeWarning ?? null,
|
||||
timeDanger: eventNow?.timeWarning ?? null,
|
||||
};
|
||||
|
||||
// prevent render until we get all the data we need
|
||||
|
||||
@@ -53,10 +53,6 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
|
||||
return event[property as keyof OntimeEvent] as string;
|
||||
}
|
||||
|
||||
export function capitaliseFirstLetter(string: string) {
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
}
|
||||
|
||||
type FormattingOptions = {
|
||||
removeSeconds: boolean;
|
||||
removeLeadingZero: boolean;
|
||||
@@ -84,7 +80,9 @@ export function getFormattedTimer(
|
||||
}
|
||||
|
||||
let display = millisToString(timeToParse);
|
||||
display = removeLeadingZero(display);
|
||||
if (options.removeLeadingZero) {
|
||||
display = removeLeadingZero(display);
|
||||
}
|
||||
|
||||
if (options.removeSeconds) {
|
||||
display = formatDisplayWithMinutes(display, localisedMinutes);
|
||||
|
||||
@@ -20,11 +20,11 @@
|
||||
}
|
||||
|
||||
.timer {
|
||||
opacity: 1;
|
||||
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
|
||||
font-size: 20vw;
|
||||
position: relative;
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
opacity: 1;
|
||||
color: var(--timer-color-override, var(--phase-color));
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
background-color: transparent;
|
||||
@@ -42,42 +42,6 @@
|
||||
|
||||
/* =================== OVERLAY ===================*/
|
||||
|
||||
.message-overlay {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: $viewer-overlay-bg-color;
|
||||
z-index: -1;
|
||||
opacity: 0;
|
||||
transition: $viewer-transition-time;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
transition: $viewer-transition-time;
|
||||
transition-property: opacity;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
width: inherit;
|
||||
padding: 2vw;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
color: white;
|
||||
transform: translate(-50%, -50%);
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
font-size: 15vw;
|
||||
line-height: 30vh;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.end-message {
|
||||
text-align: center;
|
||||
font-size: 12vw;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
|
||||
|
||||
import { overrideStylesURL } from '../../../common/api/constants';
|
||||
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
||||
@@ -15,13 +15,12 @@ import './MinimalTimer.scss';
|
||||
|
||||
interface MinimalTimerProps {
|
||||
isMirrored: boolean;
|
||||
pres: TimerMessage;
|
||||
time: ViewExtendedTimer;
|
||||
viewSettings: ViewSettings;
|
||||
}
|
||||
|
||||
export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const { isMirrored, pres, time, viewSettings } = props;
|
||||
const { isMirrored, time, viewSettings } = props;
|
||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||
const { getLocalizedString } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -117,9 +116,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
const hideOvertime = searchParams.get('hideovertime');
|
||||
userOptions.hideOvertime = isStringBoolean(hideOvertime);
|
||||
|
||||
const hideMessagesOverlay = searchParams.get('hidemessages');
|
||||
userOptions.hideMessagesOverlay = isStringBoolean(hideMessagesOverlay);
|
||||
|
||||
const hideEndMessage = searchParams.get('hideendmessage');
|
||||
userOptions.hideEndMessage = isStringBoolean(hideEndMessage);
|
||||
|
||||
@@ -128,18 +124,16 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
|
||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const showEndMessage = finished && viewSettings.endMessage && !hideEndMessage;
|
||||
const showFinished = finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
|
||||
|
||||
const showProgress = time.playback !== Playback.Stop;
|
||||
const showWarning = (time.current ?? 1) < (time.timeWarning ?? 0);
|
||||
const showDanger = (time.current ?? 1) < (time.timeDanger ?? 0);
|
||||
const showBlinking = pres.blink;
|
||||
const showBlackout = pres.blackout;
|
||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
||||
|
||||
let timerColor = viewSettings.normalColor;
|
||||
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
|
||||
@@ -155,11 +149,8 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
|
||||
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
|
||||
|
||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''} ${
|
||||
showBlinking ? (showOverlay ? '' : 'blink') : ''
|
||||
}`;
|
||||
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
|
||||
|
||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
|
||||
return (
|
||||
<div
|
||||
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
|
||||
@@ -171,25 +162,18 @@ export default function MinimalTimer(props: MinimalTimerProps) {
|
||||
data-testid='minimal-timer'
|
||||
>
|
||||
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
|
||||
{!hideMessagesOverlay && (
|
||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||
</div>
|
||||
)}
|
||||
{showEndMessage ? (
|
||||
<div className={`end-message ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
|
||||
{viewSettings.endMessage}
|
||||
</div>
|
||||
<div className='end-message'>{viewSettings.endMessage}</div>
|
||||
) : (
|
||||
<div
|
||||
className={timerClasses}
|
||||
style={{
|
||||
color: timerColor,
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
fontFamily: userOptions.font,
|
||||
top: userOptions.top,
|
||||
left: userOptions.left,
|
||||
backgroundColor: userOptions.textBackground,
|
||||
'--phase-color': timerColor,
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
.timer {
|
||||
opacity: 1;
|
||||
font-family: var(--font-family-override, $viewer-font-family);
|
||||
color: var(--timer-color-override, $timer-color);
|
||||
color: var(--timer-color-override, var(--phase-color));
|
||||
line-height: 0.9em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.05em;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Playback,
|
||||
Settings,
|
||||
TimerMessage,
|
||||
TimerPhase,
|
||||
TimerType,
|
||||
ViewSettings,
|
||||
} from 'ontime-types';
|
||||
@@ -76,6 +77,7 @@ export default function Timer(props: TimerProps) {
|
||||
hideMessage: false,
|
||||
hideTimerSeconds: false,
|
||||
hideClockSeconds: false,
|
||||
removeLeadingZeros: true,
|
||||
};
|
||||
|
||||
const hideClock = searchParams.get('hideClock');
|
||||
@@ -97,23 +99,31 @@ export default function Timer(props: TimerProps) {
|
||||
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
|
||||
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
|
||||
|
||||
const showLeadingZeros = searchParams.get('showLeadingZeros');
|
||||
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
|
||||
|
||||
const secondarySource = searchParams.get('secondary-src');
|
||||
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
|
||||
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
|
||||
|
||||
const main = searchParams.get('main');
|
||||
const mainFieldNow = (main ? getPropertyValue(eventNow, main) : eventNow?.title) ?? '';
|
||||
const mainFieldNext = (main ? getPropertyValue(eventNext, main) : eventNext?.title) ?? '';
|
||||
|
||||
const showOverlay = pres.text !== '' && pres.visible;
|
||||
const isPlaying = time.playback !== Playback.Pause;
|
||||
|
||||
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
|
||||
|
||||
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
|
||||
const finished = time.phase === TimerPhase.Overtime;
|
||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||
|
||||
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
|
||||
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
|
||||
const showEndMessage = finished && viewSettings.endMessage;
|
||||
const showProgress = time.playback !== Playback.Stop;
|
||||
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
|
||||
const showWarning = (time.current ?? 1) < (eventNow?.timeWarning ?? 0);
|
||||
const showDanger = (time.current ?? 1) < (eventNow?.timeDanger ?? 0);
|
||||
const showFinished = finished && (shouldShowModifiers || showEndMessage);
|
||||
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
|
||||
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
|
||||
const showBlinking = pres.blink;
|
||||
const showBlackout = pres.blackout;
|
||||
const showClock = time.timerType !== TimerType.Clock;
|
||||
@@ -126,7 +136,7 @@ export default function Timer(props: TimerProps) {
|
||||
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
|
||||
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
|
||||
removeSeconds: userOptions.hideTimerSeconds,
|
||||
removeLeadingZero: true,
|
||||
removeLeadingZero: userOptions.removeLeadingZeros,
|
||||
});
|
||||
|
||||
const stageTimerCharacters = display.replace('/:/g', '').length;
|
||||
@@ -169,7 +179,7 @@ export default function Timer(props: TimerProps) {
|
||||
className={timerClasses}
|
||||
style={{
|
||||
fontSize: `${timerFontSize}vw`,
|
||||
color: timerColor,
|
||||
'--phase-color': timerColor,
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
@@ -209,7 +219,7 @@ export default function Timer(props: TimerProps) {
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} />
|
||||
<TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -224,7 +234,7 @@ export default function Timer(props: TimerProps) {
|
||||
animate='visible'
|
||||
exit='exit'
|
||||
>
|
||||
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} />
|
||||
<TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -3,7 +3,7 @@ module.exports = {
|
||||
shutdownCode: 99,
|
||||
},
|
||||
reactAppUrl: {
|
||||
development: (port = 4001) => `http://localhost:${port}`,
|
||||
development: (port = 3000) => `http://localhost:${port}`,
|
||||
production: (port = 4001) => `http://localhost:${port}`,
|
||||
},
|
||||
server: {
|
||||
|
||||
+40
-9
@@ -30,6 +30,10 @@ let win;
|
||||
let splash;
|
||||
let tray = null;
|
||||
|
||||
/**
|
||||
* Coordinates the node process startup
|
||||
* @returns {number} server port - the port at which the backend has been started at
|
||||
*/
|
||||
async function startBackend() {
|
||||
// in dev mode, we expect both UI and server to be running
|
||||
if (!isProduction) {
|
||||
@@ -41,7 +45,7 @@ async function startBackend() {
|
||||
|
||||
await initAssets();
|
||||
|
||||
const result = await startServer();
|
||||
const result = await startServer(escalateError);
|
||||
loaded = result.message;
|
||||
|
||||
await startIntegrations();
|
||||
@@ -62,6 +66,9 @@ function showNotification(title, text) {
|
||||
}).show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate node service and close electron app
|
||||
*/
|
||||
function appShutdown() {
|
||||
// terminate node service
|
||||
(async () => {
|
||||
@@ -76,16 +83,30 @@ function appShutdown() {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets Ontime window in focus
|
||||
*/
|
||||
function bringToFront() {
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates the shutdown process
|
||||
*/
|
||||
function askToQuit() {
|
||||
bringToFront();
|
||||
win.send('user-request-shutdown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows processes to escalate errors to be shown in electron
|
||||
* @param {string} error
|
||||
*/
|
||||
function escalateError(error) {
|
||||
dialog.showErrorBox('An unrecoverable error occurred', error);
|
||||
}
|
||||
|
||||
// Ensure there isn't another instance of the app running already
|
||||
const lock = app.requestSingleInstanceLock();
|
||||
if (!lock) {
|
||||
@@ -103,6 +124,9 @@ if (!lock) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates creation of electron windows (splash and main)
|
||||
*/
|
||||
function createWindow() {
|
||||
splash = new BrowserWindow({
|
||||
width: 333,
|
||||
@@ -191,12 +215,16 @@ app.whenReady().then(() => {
|
||||
console.log('ERROR: Ontime failed to start', error);
|
||||
});
|
||||
|
||||
// recreate window if no others open
|
||||
/**
|
||||
* recreate window if no others open
|
||||
*/
|
||||
app.on('activate', () => {
|
||||
win.show();
|
||||
});
|
||||
|
||||
// Hide on close
|
||||
/**
|
||||
* Hide on close
|
||||
*/
|
||||
win.on('close', function (event) {
|
||||
event.preventDefault();
|
||||
if (!isQuitting) {
|
||||
@@ -213,12 +241,11 @@ app.whenReady().then(() => {
|
||||
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
|
||||
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
|
||||
tray.setContextMenu(trayContextMenu);
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
// unregister shortcuts before quitting
|
||||
/**
|
||||
* Unregister shortcuts before quitting
|
||||
*/
|
||||
app.once('will-quit', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
@@ -235,7 +262,9 @@ ipcMain.on('shutdown', () => {
|
||||
appShutdown();
|
||||
});
|
||||
|
||||
// Window manipulation
|
||||
/**
|
||||
* Handles requests to set window properties
|
||||
*/
|
||||
ipcMain.on('set-window', (event, arg) => {
|
||||
switch (arg) {
|
||||
case 'show-dev':
|
||||
@@ -246,7 +275,9 @@ ipcMain.on('set-window', (event, arg) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Open links external
|
||||
/**
|
||||
* Handles requests to open external links
|
||||
*/
|
||||
ipcMain.on('send-to-link', (event, arg) => {
|
||||
shell.openExternal(arg);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.0.4",
|
||||
"version": "3.2.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -12,19 +12,20 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "main.js",
|
||||
"devDependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"electron": "^28.3.3",
|
||||
"electron-builder": "^24.13.3",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"prettier": "^3.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"postinstall": "",
|
||||
"lint": "eslint . --quiet",
|
||||
"lint-staged": "eslint",
|
||||
"dev:electron": "cross-env NODE_ENV=development electron .",
|
||||
"dev": "cross-env NODE_ENV=development electron .",
|
||||
"dist-win": "electron-builder --publish=never --x64 --win",
|
||||
"dist-mac": "electron-builder --publish=never --mac",
|
||||
"dist-mac:local": "electron-builder --publish=never --mac -c.mac.identity=null",
|
||||
"dist-linux": "electron-builder --publish=never --x64 --linux",
|
||||
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
|
||||
},
|
||||
|
||||
@@ -124,7 +124,7 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
|
||||
{
|
||||
label: 'Operator',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/operator`);
|
||||
await shell.openExternal(`${urlBase}/op`);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.0.4",
|
||||
"version": "3.2.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
"body-parser": "^1.20.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.18.2",
|
||||
@@ -20,8 +19,7 @@
|
||||
"node-osc": "^9.0.2",
|
||||
"node-xlsx": "^0.23.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "~1.0.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"steno": "^3.1.0",
|
||||
"ts-essentials": "^9.4.1",
|
||||
"ws": "^8.13.0"
|
||||
@@ -34,14 +32,14 @@
|
||||
"@types/node-osc": "^6.0.2",
|
||||
"@types/websocket": "^1.0.5",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"esbuild": "^0.19.10",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"nodemon": "^2.0.20",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"server-timing": "^3.3.3",
|
||||
"shx": "^0.3.4",
|
||||
"ts-node": "^10.9.1",
|
||||
|
||||
@@ -16,9 +16,11 @@ import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
|
||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
|
||||
import { appStateService } from '../../services/app-state-service/AppStateService.js';
|
||||
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
|
||||
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
|
||||
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||
// all fields are optional in validation
|
||||
@@ -32,6 +34,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
|
||||
|
||||
const newData = await projectService.applyDataModel(patchDb);
|
||||
|
||||
res.status(200).send(newData);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -50,8 +53,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
|
||||
*/
|
||||
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
|
||||
try {
|
||||
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled');
|
||||
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
|
||||
const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
|
||||
const errors = projectService.validateProjectFiles({ newFilename: filename });
|
||||
|
||||
if (errors.length) {
|
||||
@@ -67,7 +69,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
backstageInfo: req.body?.backstageInfo ?? '',
|
||||
};
|
||||
|
||||
projectService.createProjectFile(filename, newProjectData);
|
||||
await projectService.createProjectFile(filename, newProjectData);
|
||||
|
||||
res.status(200).send({
|
||||
filename,
|
||||
@@ -79,29 +81,18 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function finds the correct project file to download
|
||||
*/
|
||||
function selectProjectFile(fileName?: string) {
|
||||
const projectsDirectory = resolveDbDirectory;
|
||||
const fileToDownload = fileName ? ensureJsonExtension(fileName) : projectService.getProjectTitle();
|
||||
const pathToFile = join(projectsDirectory, fileToDownload);
|
||||
|
||||
return { pathToFile, name: fileToDownload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows downloading of a optionally given project files
|
||||
* If no {filename} is provided, loaded file will be served
|
||||
* Allows downloading of project files
|
||||
*/
|
||||
export async function projectDownload(req: Request, res: Response) {
|
||||
const { pathToFile, name } = selectProjectFile(req.body?.fileName);
|
||||
const { filename } = req.body;
|
||||
const pathToFile = join(resolveDbDirectory, filename);
|
||||
|
||||
// Check if the file exists before attempting to download
|
||||
if (!existsSync(pathToFile)) {
|
||||
return res.status(404).send({ message: `Project ${name} not found.` });
|
||||
return res.status(404).send({ message: `Project ${filename} not found.` });
|
||||
}
|
||||
|
||||
res.download(pathToFile, name, (error) => {
|
||||
res.download(pathToFile, filename, (error) => {
|
||||
if (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
@@ -125,6 +116,12 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
|
||||
await projectService.handleUploadedFile(path, filename);
|
||||
await projectService.applyProjectFile(filename, options);
|
||||
|
||||
const oscSettings = await DataProvider.getOsc();
|
||||
const httpSettings = await DataProvider.getHttp();
|
||||
|
||||
oscIntegration.init(oscSettings);
|
||||
httpIntegration.init(httpSettings);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${filename}`,
|
||||
});
|
||||
@@ -159,6 +156,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
|
||||
|
||||
await projectService.applyProjectFile(name);
|
||||
|
||||
const oscSettings = await DataProvider.getOsc();
|
||||
const httpSettings = await DataProvider.getHttp();
|
||||
|
||||
oscIntegration.init(oscSettings);
|
||||
httpIntegration.init(httpSettings);
|
||||
|
||||
res.status(201).send({
|
||||
message: `Loaded project ${name}`,
|
||||
});
|
||||
@@ -212,7 +215,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
|
||||
*/
|
||||
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
|
||||
try {
|
||||
const { newFilename } = req.body;
|
||||
const { filename: newFilename } = req.body;
|
||||
const { filename } = req.params;
|
||||
|
||||
const errors = projectService.validateProjectFiles({ filename, newFilename });
|
||||
|
||||
@@ -14,28 +14,25 @@ import {
|
||||
} from './db.controller.js';
|
||||
import { uploadProjectFile } from './db.middleware.js';
|
||||
import {
|
||||
projectSanitiser,
|
||||
sanitizeProjectFilename,
|
||||
validateDownloadProject,
|
||||
validateLoadProjectFile,
|
||||
validatePatchProjectFile,
|
||||
validateProjectDuplicate,
|
||||
validateProjectRename,
|
||||
validateNewProject,
|
||||
validatePatchProject,
|
||||
validateFilenameBody,
|
||||
validateFilenameParam,
|
||||
} from './db.validation.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.post('/download', validateDownloadProject, projectDownload);
|
||||
router.post('/download', validateFilenameBody, projectDownload);
|
||||
router.post('/upload', uploadProjectFile, postProjectFile);
|
||||
|
||||
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
|
||||
router.post('/new', projectSanitiser, createProjectFile);
|
||||
router.patch('/', validatePatchProject, patchPartialProjectFile);
|
||||
router.post('/new', validateFilenameBody, validateNewProject, createProjectFile);
|
||||
|
||||
router.get('/all', listProjects);
|
||||
|
||||
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject);
|
||||
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile);
|
||||
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile);
|
||||
router.post('/load', validateFilenameBody, loadProject);
|
||||
router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile);
|
||||
router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
|
||||
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
|
||||
|
||||
router.get('/info', getInfo);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { body, validationResult } from 'express-validator';
|
||||
|
||||
import { body, param, validationResult } from 'express-validator';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
export const projectSanitiser = [
|
||||
/**
|
||||
* @description Validates request for a new project.
|
||||
*/
|
||||
export const validateNewProject = [
|
||||
body('title').optional().isString().trim(),
|
||||
body('description').optional().isString().trim(),
|
||||
body('publicUrl').optional().isString().trim(),
|
||||
@@ -19,18 +22,10 @@ export const projectSanitiser = [
|
||||
},
|
||||
];
|
||||
|
||||
export const sanitizeProjectFilename = (req: Request, _res: Response, next: NextFunction) => {
|
||||
const { filename, newFilename } = req.body;
|
||||
const { filename: projectName } = req.params;
|
||||
|
||||
req.body.filename = ensureJsonExtension(filename);
|
||||
req.body.newFilename = ensureJsonExtension(newFilename);
|
||||
req.params.filename = ensureJsonExtension(projectName);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const validatePatchProjectFile = [
|
||||
/**
|
||||
* @description Validates request for pathing data in the project.
|
||||
*/
|
||||
export const validatePatchProject = [
|
||||
body('rundown').isArray().optional({ nullable: false }),
|
||||
body('project').isObject().optional({ nullable: false }),
|
||||
body('settings').isObject().optional({ nullable: false }),
|
||||
@@ -47,31 +42,18 @@ export const validatePatchProjectFile = [
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filename for loading a project file.
|
||||
* @description Validates request with filename in the body.
|
||||
*/
|
||||
export const validateLoadProjectFile = [
|
||||
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json({ errors: errors.array() });
|
||||
}
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filenames for duplicating a project.
|
||||
*/
|
||||
export const validateProjectDuplicate = [
|
||||
body('newFilename')
|
||||
export const validateFilenameBody = [
|
||||
body('filename')
|
||||
.exists()
|
||||
.withMessage('New project filename is required')
|
||||
.isString()
|
||||
.withMessage('New project filename must be a string')
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage('New project filename must be between 1 and 255 characters'),
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
@@ -84,32 +66,18 @@ export const validateProjectDuplicate = [
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates the filenames for renaming a project.
|
||||
* @description Validates request with filename in the params.
|
||||
*/
|
||||
export const validateProjectRename = [
|
||||
body('newFilename')
|
||||
export const validateFilenameParam = [
|
||||
param('filename')
|
||||
.exists()
|
||||
.withMessage('Duplicate project filename is required')
|
||||
.isString()
|
||||
.withMessage('Duplicate project filename must be a string')
|
||||
.isLength({ min: 1, max: 255 })
|
||||
.withMessage('Duplicate project filename must be between 1 and 255 characters'),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates a download request which can include an optional project name.
|
||||
*/
|
||||
export const validateDownloadProject = [
|
||||
body('fileName').isString().optional(),
|
||||
.trim()
|
||||
.customSanitizer((input: string) => sanitize(input))
|
||||
.withMessage('Failed to sanitize the filename')
|
||||
.notEmpty()
|
||||
.withMessage('Filename was empty or contained only invalid characters')
|
||||
.customSanitizer((input: string) => ensureJsonExtension(input)),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ErrorResponse, Settings } from 'ontime-types';
|
||||
import { getErrorMessage, obfuscate } from 'ontime-utils';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@@ -6,8 +7,6 @@ import { DataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { extractPin } from '../../services/project-service/ProjectService.js';
|
||||
import { isDocker } from '../../setup/index.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { obfuscate } from 'ontime-utils';
|
||||
|
||||
export async function getSettings(_req: Request, res: Response<Settings>) {
|
||||
const settings = DataProvider.getSettings();
|
||||
|
||||
@@ -10,6 +10,10 @@ import { eventStore } from '../stores/EventStore.js';
|
||||
import * as assert from '../utils/assert.js';
|
||||
import { isEmptyObject } from '../utils/parserUtils.js';
|
||||
import { parseProperty, updateEvent } from './integration.utils.js';
|
||||
import { throttle } from '../utils/throttle.js';
|
||||
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
|
||||
|
||||
const throttledUpdateEvent = throttle(updateEvent, 20);
|
||||
|
||||
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
|
||||
const action = type.toLowerCase();
|
||||
@@ -43,6 +47,8 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
const data = payload[id as keyof typeof payload];
|
||||
const patchEvent: Partial<OntimeEvent> & { id: string } = { id };
|
||||
|
||||
let shouldThrottle = false;
|
||||
|
||||
Object.entries(data).forEach(([property, value]) => {
|
||||
if (typeof property !== 'string' || value === undefined) {
|
||||
throw new Error('Invalid property or value');
|
||||
@@ -50,6 +56,9 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
|
||||
const newObjectProperty = parseProperty(property, value);
|
||||
|
||||
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
|
||||
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
|
||||
|
||||
if (patchEvent.custom && newObjectProperty.custom) {
|
||||
Object.assign(patchEvent.custom, newObjectProperty.custom);
|
||||
} else {
|
||||
@@ -57,8 +66,13 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
}
|
||||
});
|
||||
|
||||
updateEvent(patchEvent);
|
||||
|
||||
if (shouldThrottle) {
|
||||
if (throttledUpdateEvent(patchEvent)) {
|
||||
return { payload: 'throttled' };
|
||||
}
|
||||
} else {
|
||||
updateEvent(patchEvent);
|
||||
}
|
||||
return { payload: 'success' };
|
||||
},
|
||||
/* Message Service */
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
|
||||
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
|
||||
|
||||
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||
import { editEvent } from '../services/rundown-service/RundownService.js';
|
||||
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
|
||||
import { coerceBoolean, coerceColour, coerceNumber, coerceString } from '../utils/coerceType.js';
|
||||
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
|
||||
|
||||
const whitelistedPayload = {
|
||||
/**
|
||||
*
|
||||
* @param {number} value time amount in seconds
|
||||
* @returns {number} time in milliseconds clamped to 0 and max duration
|
||||
*/
|
||||
function clampDuration(value: number) {
|
||||
const valueInMillis = value * MILLIS_PER_SECOND;
|
||||
if (valueInMillis > maxDuration || valueInMillis < 0) {
|
||||
throw new Error('Times should be from 0 to 23:59:59');
|
||||
}
|
||||
return valueInMillis;
|
||||
}
|
||||
|
||||
const propertyConversion = {
|
||||
title: coerceString,
|
||||
note: coerceString,
|
||||
cue: coerceString,
|
||||
|
||||
duration: (value: unknown) => Math.max(coerceNumber(value) * MILLIS_PER_SECOND, maxDuration),
|
||||
|
||||
isPublic: coerceBoolean,
|
||||
skip: coerceBoolean,
|
||||
|
||||
colour: coerceColour,
|
||||
|
||||
custom: coerceString,
|
||||
|
||||
timeWarning: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
timeDanger: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
|
||||
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
|
||||
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
|
||||
|
||||
duration: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
|
||||
};
|
||||
|
||||
export function parseProperty(property: string, value: unknown) {
|
||||
@@ -27,13 +48,13 @@ export function parseProperty(property: string, value: unknown) {
|
||||
if (!(customKey in DataProvider.getCustomFields())) {
|
||||
throw new Error(`Custom field ${customKey} not found`);
|
||||
}
|
||||
const parserFn = whitelistedPayload.custom;
|
||||
const parserFn = propertyConversion.custom;
|
||||
return { custom: { [customKey]: parserFn(value) } };
|
||||
}
|
||||
if (!isKeyOfType(property, whitelistedPayload)) {
|
||||
if (!isKeyOfType(property, propertyConversion)) {
|
||||
throw new Error(`Property ${property} not permitted`);
|
||||
}
|
||||
const parserFn = whitelistedPayload[property];
|
||||
const parserFn = propertyConversion[property];
|
||||
return { [property]: parserFn(value) };
|
||||
}
|
||||
|
||||
@@ -50,6 +71,5 @@ export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
|
||||
if (!isOntimeEvent(event)) {
|
||||
throw new Error('Can only update events');
|
||||
}
|
||||
|
||||
editEvent(patchEvent);
|
||||
}
|
||||
|
||||
+27
-16
@@ -20,6 +20,7 @@ import {
|
||||
clearUploadfolder,
|
||||
} from './setup/index.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { consoleSuccess, consoleHighlight } from './utils/console.js';
|
||||
|
||||
// Import Routers
|
||||
import { appRouter } from './api-data/index.js';
|
||||
@@ -44,10 +45,13 @@ import { populateDemo } from './setup/loadDemo.js';
|
||||
import { getState } from './stores/runtimeState.js';
|
||||
import { initRundown } from './services/rundown-service/RundownService.js';
|
||||
import { generateCrashReport } from './utils/generateCrashReport.js';
|
||||
import { getNetworkInterfaces } from './utils/networkInterfaces.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
console.log('\n');
|
||||
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
if (!isProduction) {
|
||||
const canLog = isProduction;
|
||||
if (!canLog) {
|
||||
console.log(`Ontime running in ${environment} environment`);
|
||||
console.log(`Ontime directory at ${srcDirectory} `);
|
||||
console.log(`Ontime database at ${resolveDbPath}`);
|
||||
@@ -56,7 +60,7 @@ if (!isProduction) {
|
||||
// Create express APP
|
||||
const app = express();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// log more serever timings
|
||||
// log server timings to requests
|
||||
app.use(serverTiming());
|
||||
}
|
||||
app.disable('x-powered-by');
|
||||
@@ -151,18 +155,16 @@ export const initAssets = async () => {
|
||||
|
||||
/**
|
||||
* Starts servers
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
export const startServer = async () => {
|
||||
export const startServer = async (
|
||||
escalateErrorFn?: (error: string) => void,
|
||||
): Promise<{ message: string; serverPort: number }> => {
|
||||
checkStart(OntimeStartOrder.InitServer);
|
||||
|
||||
const { serverPort } = DataProvider.getSettings();
|
||||
|
||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
||||
|
||||
expressServer = http.createServer(app);
|
||||
socket.init(expressServer);
|
||||
logger.info(LogOrigin.Server, returnMessage);
|
||||
|
||||
/**
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
@@ -186,6 +188,9 @@ export const startServer = async () => {
|
||||
},
|
||||
});
|
||||
|
||||
// initialise logging service, escalateErrorFn is only exists in electron
|
||||
logger.init(escalateErrorFn);
|
||||
|
||||
// initialise rundown service
|
||||
const persistedRundown = DataProvider.getRundown();
|
||||
const persistedCustomFields = DataProvider.getCustomFields();
|
||||
@@ -200,7 +205,16 @@ export const startServer = async () => {
|
||||
// eventStore set is a dependency of the services that publish to it
|
||||
messageService.init(eventStore.set.bind(eventStore));
|
||||
|
||||
expressServer.listen(serverPort, '0.0.0.0');
|
||||
expressServer.listen(serverPort, '0.0.0.0', () => {
|
||||
const nif = getNetworkInterfaces();
|
||||
consoleSuccess(`Local: http://localhost:${serverPort}/editor`);
|
||||
for (const key of Object.keys(nif)) {
|
||||
consoleSuccess(`Network: http://${nif[key].address}:${serverPort}/editor`);
|
||||
}
|
||||
});
|
||||
|
||||
const returnMessage = `Ontime is listening on port ${serverPort}`;
|
||||
logger.info(LogOrigin.Server, returnMessage);
|
||||
|
||||
return { message: returnMessage, serverPort };
|
||||
};
|
||||
@@ -241,7 +255,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpS
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
export const shutdown = async (exitCode = 0) => {
|
||||
console.log(`Ontime shutting down with code ${exitCode}`);
|
||||
consoleHighlight(`Ontime shutting down with code ${exitCode}`);
|
||||
|
||||
// clear the restore file if it was a normal exit
|
||||
// 0 means it was a SIGNAL
|
||||
@@ -251,7 +265,6 @@ export const shutdown = async (exitCode = 0) => {
|
||||
await restoreService.clear();
|
||||
}
|
||||
|
||||
// TODO: Clear token
|
||||
expressServer?.close();
|
||||
runtimeService.shutdown();
|
||||
integrationService.shutdown();
|
||||
@@ -260,19 +273,17 @@ export const shutdown = async (exitCode = 0) => {
|
||||
process.exit(exitCode);
|
||||
};
|
||||
|
||||
process.on('exit', (code) => console.log(`Ontime shutdown with code: ${code}`));
|
||||
process.on('exit', (code) => consoleHighlight(`Ontime shutdown with code: ${code}`));
|
||||
|
||||
process.on('unhandledRejection', async (error) => {
|
||||
console.error('Error: unhandled rejection', error);
|
||||
generateCrashReport(error);
|
||||
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`);
|
||||
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', async (error) => {
|
||||
console.error('Error: uncaught exception', error);
|
||||
generateCrashReport(error);
|
||||
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`);
|
||||
logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
|
||||
await shutdown(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,33 @@ import { generateId, millisToString } from 'ontime-utils';
|
||||
import { clock } from '../services/Clock.js';
|
||||
import { isProduction } from '../setup/index.js';
|
||||
import { socket } from '../adapters/WebsocketAdapter.js';
|
||||
import { consoleSubdued, consoleRed } from '../utils/console.js';
|
||||
|
||||
class Logger {
|
||||
private queue: Log[];
|
||||
private escalateErrorFn: (error: string) => void | null;
|
||||
private canLog = false;
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.escalateErrorFn = null;
|
||||
this.canLog = !isProduction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabling setup logger after init
|
||||
*/
|
||||
init() {
|
||||
init(escalateErrorFn: (error: string) => void) {
|
||||
// flush logs from queue
|
||||
this.queue.forEach((log) => {
|
||||
this._push(log);
|
||||
});
|
||||
this.queue = [];
|
||||
|
||||
// we only get this when running in electron
|
||||
if (escalateErrorFn) {
|
||||
this.escalateErrorFn = escalateErrorFn;
|
||||
}
|
||||
}
|
||||
|
||||
private addToQueue(log: Log) {
|
||||
@@ -34,8 +45,12 @@ class Logger {
|
||||
* @param log
|
||||
*/
|
||||
private _push(log: Log) {
|
||||
if (!isProduction) {
|
||||
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
if (this.canLog || log.level === LogLevel.Severe) {
|
||||
if (log.level === LogLevel.Severe) {
|
||||
consoleRed(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
} else {
|
||||
consoleSubdued(`[${log.level}] \t ${log.origin} \t ${log.text}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -92,11 +107,20 @@ class Logger {
|
||||
this.emit(LogLevel.Error, origin, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to emit logging message of type SEVERE
|
||||
* @param origin
|
||||
* @param text
|
||||
*/
|
||||
crash(origin: string, text: string) {
|
||||
this.emit(LogLevel.Severe, origin, text);
|
||||
this.escalateErrorFn?.(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown logger
|
||||
*/
|
||||
shutdown() {
|
||||
console.log('Shutting down logger');
|
||||
this.queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/* eslint-disable no-console */
|
||||
import { consoleHighlight, consoleRed } from './utils/console.js';
|
||||
import { initAssets, startIntegrations, startServer } from './app.js';
|
||||
|
||||
async function startOntime() {
|
||||
try {
|
||||
console.log('Request: Initialise assets...');
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Initialise assets...');
|
||||
await initAssets();
|
||||
console.log('Request: Start server...');
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Start server...');
|
||||
await startServer();
|
||||
console.log('Request: Start integrations...');
|
||||
|
||||
console.log('\n');
|
||||
consoleHighlight('Request: Start integrations...');
|
||||
await startIntegrations();
|
||||
} catch (error) {
|
||||
console.log(`Request failed: ${error}`);
|
||||
consoleRed(`Request failed: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
|
||||
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
|
||||
|
||||
import {
|
||||
getCurrent,
|
||||
getExpectedFinish,
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
getTotalDuration,
|
||||
normaliseEndTime,
|
||||
skippedOutOfEvent,
|
||||
@@ -977,6 +978,32 @@ describe('getRollTimers()', () => {
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('loads upcoming event while waiting to roll', () => {
|
||||
const singleEventList: Partial<OntimeEvent>[] = [
|
||||
{
|
||||
id: '1',
|
||||
timeStart: 72000000, // 20:00
|
||||
timeEnd: 72010000, // 20:10
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
const now = 6000; // 00:01
|
||||
const expected = {
|
||||
nowIndex: null,
|
||||
nowId: null,
|
||||
publicIndex: null,
|
||||
nextIndex: 0,
|
||||
publicNextIndex: 0,
|
||||
timeToNext: 72000000 - now,
|
||||
nextEvent: singleEventList[0],
|
||||
nextPublicEvent: singleEventList[0],
|
||||
currentEvent: null,
|
||||
currentPublicEvent: null,
|
||||
};
|
||||
const state = getRollTimers(singleEventList as OntimeEvent[], now);
|
||||
expect(state).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('handles roll that goes over midnight', () => {
|
||||
const singleEventList: Partial<OntimeEvent>[] = [
|
||||
{
|
||||
@@ -1756,3 +1783,177 @@ describe('getTotalDuration()', () => {
|
||||
expect(millisToString(duration)).toBe('62:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimerPhase()', () => {
|
||||
it('should be None if the timer is not running', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: null,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
playback: Playback.Stop,
|
||||
phase: TimerPhase.None,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.None);
|
||||
});
|
||||
|
||||
it('can be in overtime', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: -50,
|
||||
duration: 1000,
|
||||
playback: Playback.Play,
|
||||
},
|
||||
eventNow: {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Overtime);
|
||||
});
|
||||
|
||||
it('can be danger', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: 0,
|
||||
duration: 1000,
|
||||
playback: Playback.Play,
|
||||
},
|
||||
eventNow: {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Danger);
|
||||
});
|
||||
|
||||
it('can be warning', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: 150,
|
||||
duration: 1000,
|
||||
playback: Playback.Play,
|
||||
},
|
||||
eventNow: {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Warning);
|
||||
});
|
||||
|
||||
it('it default if the timer is playing and there is none of the above', () => {
|
||||
const state = {
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: 250,
|
||||
duration: 1000,
|
||||
playback: Playback.Play,
|
||||
},
|
||||
eventNow: {
|
||||
timeDanger: 100,
|
||||
timeWarning: 200,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Default);
|
||||
});
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
eventNow: null,
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNext: null,
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: 0,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: 'none',
|
||||
playback: 'roll',
|
||||
secondaryTimer: 168950,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
});
|
||||
|
||||
it('#1042 identifies waiting to roll', () => {
|
||||
const state = {
|
||||
clock: 55691050,
|
||||
eventNow: null,
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNext: null,
|
||||
runtime: {
|
||||
selectedEventIndex: null,
|
||||
numEvents: 1,
|
||||
offset: null,
|
||||
plannedStart: 55860000,
|
||||
plannedEnd: 55880000,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
},
|
||||
timer: {
|
||||
addedTime: 0,
|
||||
current: null,
|
||||
duration: null,
|
||||
elapsed: 0,
|
||||
expectedFinish: null,
|
||||
finishedAt: null,
|
||||
phase: 'none',
|
||||
playback: 'roll',
|
||||
secondaryTimer: 168950,
|
||||
startedAt: null,
|
||||
},
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: 55860000,
|
||||
},
|
||||
} as RuntimeState;
|
||||
|
||||
const phase = getTimerPhase(state);
|
||||
expect(phase).toBe(TimerPhase.Pending);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,9 @@ import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } fro
|
||||
|
||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||
import { parseTemplateNested } from './integrationUtils.js';
|
||||
import { isObject } from '../../utils/varUtils.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
|
||||
@@ -60,27 +60,24 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
|
||||
}
|
||||
const parsedAddress = parseTemplateNested(address, state || {});
|
||||
const parsedPayload = payload ? parseTemplateNested(payload, state || {}) : undefined;
|
||||
const parsedArguments = stringToOSCArgs(parsedPayload);
|
||||
|
||||
try {
|
||||
this.emit(parsedAddress, parsedPayload);
|
||||
this.emit(parsedAddress, parsedArguments);
|
||||
} catch (error) {
|
||||
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(address: string, payload?: ArgumentType) {
|
||||
emit(address: string, args: ArgumentType[]) {
|
||||
if (!this.oscClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
//TODO: Look into using bundles
|
||||
const message = new Message(address);
|
||||
if (payload) {
|
||||
if (isObject(payload)) {
|
||||
message.append(JSON.stringify(payload));
|
||||
} else {
|
||||
message.append(payload);
|
||||
}
|
||||
}
|
||||
message.append(args);
|
||||
|
||||
this.oscClient.send(message);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
|
||||
import { parseTemplateNested } from './integrationUtils.js';
|
||||
|
||||
describe('parseTemplateNested()', () => {
|
||||
@@ -96,3 +97,73 @@ describe('parseNestedTemplate() -> resolveAliasData()', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export function handleCustomField(
|
||||
}
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum regenerateWhitelist {
|
||||
export enum regenerateWhitelist {
|
||||
'id',
|
||||
'cue',
|
||||
'title',
|
||||
@@ -122,6 +122,14 @@ export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
|
||||
return Object.keys(patch).some((key) => !(key in regenerateWhitelist));
|
||||
}
|
||||
|
||||
/**
|
||||
* given a key, returns whether it is whitelisted
|
||||
* @param path
|
||||
*/
|
||||
export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
|
||||
return !(key in regenerateWhitelist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an event and a patch to that event checks whether there are actual changes to the dataset
|
||||
* @param existingEvent
|
||||
|
||||
@@ -26,8 +26,7 @@ export function getShouldTimerUpdate(previousValue: number, currentValue: MaybeN
|
||||
return false;
|
||||
}
|
||||
// we avoid trigger ahead since it can cause duplicate triggers
|
||||
// we force the timer value to be negative because we need a ceiling reduction
|
||||
const shouldUpdateTimer = millisToSeconds(-currentValue) !== millisToSeconds(-previousValue);
|
||||
const shouldUpdateTimer = millisToSeconds(currentValue) !== millisToSeconds(previousValue);
|
||||
return shouldUpdateTimer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types';
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
import { RuntimeState } from '../stores/runtimeState.js';
|
||||
import { timerConfig } from '../config/config.js';
|
||||
@@ -118,18 +118,21 @@ type RollTimers = {
|
||||
* Finds loading information given a current rundown and time
|
||||
* @param {OntimeEvent[]} rundown - List of playable events
|
||||
* @param {number} timeNow - time now in ms
|
||||
* @returns {{}}
|
||||
*/
|
||||
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTimers => {
|
||||
let nowIndex: number | null = null; // index of event now
|
||||
let nowId: string | null = null; // id of event now
|
||||
let publicIndex: number | null = null; // index of public event now
|
||||
let nextIndex: number | null = null; // index of next event
|
||||
let publicNextIndex: number | null = null; // index of next public event
|
||||
let timeToNext: number | null = null; // counter: time for next event
|
||||
let publicTimeToNext: number | null = null; // counter: time for next public event
|
||||
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
|
||||
let nowIndex: MaybeNumber = null; // index of event now
|
||||
let nowId: MaybeString = null; // id of event now
|
||||
let publicIndex: MaybeNumber = null; // index of public event now
|
||||
let nextIndex: MaybeNumber = null; // index of next event
|
||||
let publicNextIndex: MaybeNumber = null; // index of next public event
|
||||
let timeToNext: MaybeNumber = null; // counter: time for next event
|
||||
let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
|
||||
|
||||
const lastEvent = rundown[rundown.length - 1];
|
||||
const hasLoaded = currentIndex !== null;
|
||||
const canFilter = hasLoaded && currentIndex === rundown.length - 1;
|
||||
const filteredRundown = canFilter ? rundown.slice(currentIndex) : rundown;
|
||||
|
||||
const lastEvent = filteredRundown.at(-1);
|
||||
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
|
||||
|
||||
let nextEvent: OntimeEvent | null = null;
|
||||
@@ -141,7 +144,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
// we are past last end
|
||||
// preload first and find next
|
||||
|
||||
const firstEvent = rundown[0];
|
||||
const firstEvent = filteredRundown.at(0);
|
||||
nextIndex = 0;
|
||||
nextEvent = firstEvent;
|
||||
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
|
||||
@@ -153,11 +156,11 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
// look for next public
|
||||
// dev note: we feel that this is more efficient than filtering
|
||||
// since the next event will likely be close to the one playing
|
||||
for (const event of rundown) {
|
||||
for (const event of filteredRundown) {
|
||||
if (event.isPublic) {
|
||||
nextPublicEvent = event;
|
||||
// we need the index before this was sorted
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -168,7 +171,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
// keep track of the end times when looking for public
|
||||
let publicTime = -1;
|
||||
|
||||
for (const event of rundown) {
|
||||
for (const event of filteredRundown) {
|
||||
// When does the event end (handle midnight)
|
||||
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
|
||||
|
||||
@@ -183,12 +186,12 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
// public event might not be the one running
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (hasNotEnded && hasStarted && !nowFound) {
|
||||
// event is running
|
||||
currentEvent = event;
|
||||
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
nowId = event.id;
|
||||
nowFound = true;
|
||||
|
||||
@@ -196,7 +199,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
if (event.isPublic) {
|
||||
publicTime = normalEnd;
|
||||
currentPublicEvent = event;
|
||||
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
} else if (normalEnd > timeNow) {
|
||||
// event will run
|
||||
@@ -214,7 +217,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
if (nextIndex === null || timeToEventStart < timeToNext) {
|
||||
timeToNext = timeToEventStart;
|
||||
nextEvent = event;
|
||||
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
nextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
|
||||
if (event.isPublic) {
|
||||
@@ -222,7 +225,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
|
||||
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
|
||||
publicTimeToNext = timeToEventStart;
|
||||
nextPublicEvent = event;
|
||||
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,3 +361,48 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
}
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility checks whether the playback is considered to be active
|
||||
* @param state
|
||||
* @returns
|
||||
*/
|
||||
function isPlaybackActive(state: RuntimeState): boolean {
|
||||
return (
|
||||
state.timer.playback === Playback.Play ||
|
||||
state.timer.playback === Playback.Pause ||
|
||||
state.timer.playback === Playback.Roll
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks running timer to see which phase it currently is in
|
||||
* @param state
|
||||
*/
|
||||
export function getTimerPhase(state: RuntimeState): TimerPhase {
|
||||
if (!isPlaybackActive(state)) {
|
||||
return TimerPhase.None;
|
||||
}
|
||||
|
||||
const current = state.timer.current;
|
||||
|
||||
if (current === null || state.eventNow === null) {
|
||||
return TimerPhase.Pending;
|
||||
}
|
||||
|
||||
if (current < 0) {
|
||||
return TimerPhase.Overtime;
|
||||
}
|
||||
|
||||
const danger = state.eventNow.timeDanger;
|
||||
if (current <= danger) {
|
||||
return TimerPhase.Danger;
|
||||
}
|
||||
|
||||
const warning = state.eventNow.timeWarning;
|
||||
if (current <= warning) {
|
||||
return TimerPhase.Warning;
|
||||
}
|
||||
|
||||
return TimerPhase.Default;
|
||||
}
|
||||
|
||||
@@ -59,17 +59,13 @@ const currentDir = dirname(__dirname);
|
||||
export const srcDirectory = isProduction ? currentDir : path.join(currentDir, '../');
|
||||
|
||||
// resolve path to external
|
||||
const productionPath = path.join(srcDirectory, '../../resources/extraResources/client');
|
||||
const productionPath = path.join(srcDirectory, 'client/');
|
||||
const devPath = path.join(srcDirectory, '../../client/build/');
|
||||
const dockerPath = path.join(srcDirectory, 'client/');
|
||||
|
||||
export const resolvedPath = (): string => {
|
||||
if (isTest) {
|
||||
return devPath;
|
||||
}
|
||||
if (isDocker) {
|
||||
return dockerPath;
|
||||
}
|
||||
if (isProduction) {
|
||||
return productionPath;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types';
|
||||
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types';
|
||||
import { calculateDuration, dayInMs } from 'ontime-utils';
|
||||
|
||||
import { clock } from '../services/Clock.js';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getExpectedFinish,
|
||||
getRollTimers,
|
||||
getRuntimeOffset,
|
||||
getTimerPhase,
|
||||
skippedOutOfEvent,
|
||||
updateRoll,
|
||||
} from '../services/timerUtils.js';
|
||||
@@ -32,6 +33,7 @@ const initialTimer: TimerState = {
|
||||
elapsed: null,
|
||||
expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients
|
||||
finishedAt: null,
|
||||
phase: TimerPhase.None,
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
@@ -300,6 +302,10 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
state.runtime.actualStart = state.clock;
|
||||
}
|
||||
|
||||
// update timer phase
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
|
||||
// update offset
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
|
||||
|
||||
@@ -387,6 +393,9 @@ export function update(): UpdateResult {
|
||||
runtimeState.timer.duration = runtimeState.timer.current;
|
||||
}
|
||||
|
||||
// update timer phase
|
||||
runtimeState.timer.phase = getTimerPhase(runtimeState);
|
||||
|
||||
// update offset
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
@@ -434,10 +443,11 @@ export function update(): UpdateResult {
|
||||
}
|
||||
|
||||
export function roll(rundown: OntimeEvent[]) {
|
||||
const selectedEventIndex = runtimeState.runtime.selectedEventIndex;
|
||||
clear();
|
||||
|
||||
runtimeState.runtime.numEvents = rundown.length;
|
||||
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock);
|
||||
|
||||
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock, selectedEventIndex);
|
||||
|
||||
if (currentEvent) {
|
||||
// there is something running, load
|
||||
@@ -456,6 +466,10 @@ export function roll(rundown: OntimeEvent[]) {
|
||||
current: endTime - runtimeState.clock,
|
||||
});
|
||||
} else if (nextEvent) {
|
||||
if (nextEvent.isPublic) {
|
||||
runtimeState.publicEventNext = nextEvent;
|
||||
}
|
||||
runtimeState.eventNext = nextEvent;
|
||||
// account for day after
|
||||
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
|
||||
// nothing now, but something coming up
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { coerceColour } from '../coerceType.js';
|
||||
import { coerceColour, coerceEnum } from '../coerceType.js';
|
||||
|
||||
describe('parses a colour string that is', () => {
|
||||
it('valid hex', () => {
|
||||
@@ -18,4 +18,29 @@ describe('parses a colour string that is', () => {
|
||||
it('not a string', () => {
|
||||
expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received'));
|
||||
});
|
||||
it('undefinde and null are not valid', () => {
|
||||
expect(() => coerceColour(null)).toThrowError(Error('Invalid colour value received'));
|
||||
expect(() => coerceColour(undefined)).toThrowError(Error('Invalid colour value received'));
|
||||
});
|
||||
it('empty string is allowed', () => {
|
||||
expect(coerceColour('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('match a string to an enum that is', () => {
|
||||
enum testEnum {
|
||||
ABC = 'abc',
|
||||
DEF = 'def',
|
||||
GHI = 'ghi',
|
||||
}
|
||||
it('valid key', () => {
|
||||
const key = coerceEnum<testEnum>('abc', testEnum);
|
||||
expect(key).toBe('abc');
|
||||
});
|
||||
it('invalid key', () => {
|
||||
expect(() => coerceEnum('123', testEnum)).toThrow();
|
||||
});
|
||||
it('invalid type', () => {
|
||||
expect(() => coerceEnum(123, testEnum)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { stringToOSCArgs } from '../oscArgParser.js';
|
||||
|
||||
describe('test stringToOSCArgs()', () => {
|
||||
it('all types', () => {
|
||||
const test = 'test 1111 0.1111 TRUE FALSE';
|
||||
const expected = [
|
||||
{ type: 'string', value: 'test' },
|
||||
{ type: 'integer', value: 1111 },
|
||||
{ type: 'float', value: 0.1111 },
|
||||
{ type: 'T', value: true },
|
||||
{ type: 'F', value: false },
|
||||
];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('empty is nothing', () => {
|
||||
const test = undefined;
|
||||
const expected = [];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('empty is nothing', () => {
|
||||
const test = '';
|
||||
const expected = [];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('1 space is nothing', () => {
|
||||
const test = ' ';
|
||||
const expected = [];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('keep other types in strings', () => {
|
||||
const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
|
||||
const expected = [
|
||||
{ type: 'string', value: 'test' },
|
||||
{ type: 'string', value: '1111' },
|
||||
{ type: 'string', value: '0.1111' },
|
||||
{ type: 'string', value: 'TRUE' },
|
||||
{ type: 'string', value: 'FALSE' },
|
||||
];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('keep spaces in quoted strings', () => {
|
||||
const test = '"test space" 1111 0.1111 TRUE FALSE';
|
||||
const expected = [
|
||||
{ type: 'string', value: 'test space' },
|
||||
{ type: 'integer', value: 1111 },
|
||||
{ type: 'float', value: 0.1111 },
|
||||
{ type: 'T', value: true },
|
||||
{ type: 'F', value: false },
|
||||
];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('keep spaces escaped quotes', () => {
|
||||
const test = '"test \\" space" 1111 0.1111 TRUE FALSE';
|
||||
const expected = [
|
||||
{ type: 'string', value: 'test " space' },
|
||||
{ type: 'integer', value: 1111 },
|
||||
{ type: 'float', value: 0.1111 },
|
||||
{ type: 'T', value: true },
|
||||
{ type: 'F', value: false },
|
||||
];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('2 spaces', () => {
|
||||
const test = '1111 0.1111 TRUE FALSE';
|
||||
const expected = [
|
||||
{ type: 'integer', value: 1111 },
|
||||
{ type: 'float', value: 0.1111 },
|
||||
{ type: 'T', value: true },
|
||||
{ type: 'F', value: false },
|
||||
];
|
||||
expect(stringToOSCArgs(test)).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,21 @@
|
||||
import { CustomFields, HttpSubscription, OscSubscription } from 'ontime-types';
|
||||
import { sanitiseCustomFields, sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../parserFunctions.js';
|
||||
import {
|
||||
CustomFields,
|
||||
DatabaseModel,
|
||||
EndAction,
|
||||
HttpSubscription,
|
||||
OntimeEvent,
|
||||
OntimeRundown,
|
||||
OscSubscription,
|
||||
SupportedEvent,
|
||||
TimeStrategy,
|
||||
TimerType,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
parseRundown,
|
||||
sanitiseCustomFields,
|
||||
sanitiseHttpSubscriptions,
|
||||
sanitiseOscSubscriptions,
|
||||
} from '../parserFunctions.js';
|
||||
|
||||
describe('sanitiseOscSubscriptions()', () => {
|
||||
it('returns an empty array if not an array', () => {
|
||||
@@ -110,7 +126,7 @@ describe('sanitiseCustomFields()', () => {
|
||||
|
||||
it('label can not be empty', () => {
|
||||
const customFields: CustomFields = {
|
||||
['']: { label: '', type: 'string', colour: 'red' },
|
||||
'': { label: '', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual({});
|
||||
@@ -133,7 +149,7 @@ describe('sanitiseCustomFields()', () => {
|
||||
test: { label: 'New Name', type: 'string', colour: 'red' },
|
||||
};
|
||||
const expectedCustomFields: CustomFields = {
|
||||
['new name']: { label: 'New Name', type: 'string', colour: 'red' },
|
||||
'new name': { label: 'New Name', type: 'string', colour: 'red' },
|
||||
};
|
||||
const sanitationResult = sanitiseCustomFields(customFields);
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
@@ -155,3 +171,113 @@ describe('sanitiseCustomFields()', () => {
|
||||
expect(sanitationResult).toStrictEqual(expectedCustomFields);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRundown() linking', () => {
|
||||
const blankEvent: OntimeEvent = {
|
||||
id: '',
|
||||
type: SupportedEvent.Event,
|
||||
cue: '',
|
||||
title: '',
|
||||
note: '',
|
||||
endAction: EndAction.None,
|
||||
timerType: TimerType.CountDown,
|
||||
linkStart: null,
|
||||
timeStrategy: TimeStrategy.LockDuration,
|
||||
timeStart: 0,
|
||||
timeEnd: 0,
|
||||
duration: 0,
|
||||
isPublic: false,
|
||||
skip: false,
|
||||
colour: '',
|
||||
revision: 0,
|
||||
timeWarning: 120000,
|
||||
timeDanger: 60000,
|
||||
custom: {},
|
||||
};
|
||||
|
||||
it('returns linked events', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
};
|
||||
|
||||
const expected: OntimeRundown = [
|
||||
{ ...blankEvent, id: '1', cue: '0' },
|
||||
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
|
||||
];
|
||||
const result = parseRundown(data);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns unlinkd if no previous', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
};
|
||||
|
||||
const expected: OntimeRundown = [{ ...blankEvent, id: '2', cue: '0' }];
|
||||
const result = parseRundown(data);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns linked events past blocks and delays', () => {
|
||||
const data: Partial<DatabaseModel> = {
|
||||
rundown: [
|
||||
{
|
||||
id: '1',
|
||||
type: SupportedEvent.Event,
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: 'delay1',
|
||||
type: SupportedEvent.Delay,
|
||||
duration: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
{
|
||||
id: 'block1',
|
||||
type: SupportedEvent.Block,
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: SupportedEvent.Event,
|
||||
linkStart: 'true',
|
||||
skip: false,
|
||||
} as OntimeEvent,
|
||||
],
|
||||
};
|
||||
|
||||
const expected: OntimeRundown = [
|
||||
{ ...blankEvent, id: '1', cue: '0' },
|
||||
{ id: 'delay1', type: SupportedEvent.Delay, duration: 0 },
|
||||
{ ...blankEvent, id: '2', cue: '1', linkStart: '1' },
|
||||
{ id: 'block1', type: SupportedEvent.Block, title: '' },
|
||||
{ ...blankEvent, id: '3', cue: '2', linkStart: '2' },
|
||||
];
|
||||
const result = parseRundown(data);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { isColourHex } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* @description Converts a value to an item in the provided enume.
|
||||
* @param {unknown} value - Value to be converted.
|
||||
* @returns {T} - The converted value as key of the enum.
|
||||
* @throws {Error} Throws an error value is not found in the enum.
|
||||
*/
|
||||
export function coerceEnum<T>(value: unknown, list: object): T {
|
||||
if (typeof value !== 'string' || !Object.values(list).includes(value)) {
|
||||
throw new Error('Invalid value received');
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
//TODO: write tests
|
||||
/**
|
||||
* @description Converts a value to a string if possible, throws otherwise
|
||||
@@ -77,7 +90,12 @@ export function coerceColour(value: unknown): string {
|
||||
if (!isColourHex(lowerCaseValue)) {
|
||||
throw new Error('Invalid hex colour received');
|
||||
}
|
||||
} else if (!(lowerCaseValue in cssColours)) {
|
||||
return lowerCaseValue;
|
||||
}
|
||||
if (lowerCaseValue === '') {
|
||||
return lowerCaseValue; // None colour the same as the UI 'Ø' button
|
||||
}
|
||||
if (!(lowerCaseValue in cssColours)) {
|
||||
throw new Error('Invalid colour name received');
|
||||
}
|
||||
return lowerCaseValue;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
/**
|
||||
* Utility function to log messages in green
|
||||
*/
|
||||
export function consoleSuccess(message: string) {
|
||||
console.log(inGreen(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to log messages in red
|
||||
*/
|
||||
export function consoleRed(message: string) {
|
||||
console.error(inRed(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to log messages with dimmed appearance
|
||||
*/
|
||||
export function consoleHighlight(message: string) {
|
||||
console.log(inCyan(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to log messages with dimmed appearance
|
||||
*/
|
||||
export function consoleSubdued(message: string) {
|
||||
console.log(inGray(message));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for console, styles text in green
|
||||
*/
|
||||
function inGreen(message: string): string {
|
||||
return `\x1b[32m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for console, styles text in red
|
||||
*/
|
||||
function inRed(message: string): string {
|
||||
return `\x1b[31m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for console, styles text in cyan
|
||||
*/
|
||||
function inCyan(message: string): string {
|
||||
return `\x1b[96m${message}\x1b[0m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for console, styles text in gray
|
||||
*/
|
||||
function inGray(message: string): string {
|
||||
return `\x1b[2m${message}\x1b[0m`;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Argument } from 'node-osc';
|
||||
import { splitWhitespace } from 'ontime-utils';
|
||||
|
||||
export function stringToOSCArgs(argsString: string | undefined): Argument[] {
|
||||
if (typeof argsString === 'undefined' || argsString === '') {
|
||||
return new Array<Argument>();
|
||||
}
|
||||
const matches = splitWhitespace(argsString);
|
||||
|
||||
if (!matches) {
|
||||
return new Array<Argument>();
|
||||
}
|
||||
|
||||
const parsedArguments: Argument[] = matches.map((argString: string) => {
|
||||
const argAsNum = Number(argString);
|
||||
// NOTE: number like: 1 2.0 33333
|
||||
if (!Number.isNaN(argAsNum)) {
|
||||
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
|
||||
}
|
||||
|
||||
if (argString.startsWith('"') && argString.endsWith('"')) {
|
||||
// NOTE: "quoted string"
|
||||
return { type: 'string', value: argString.substring(1, argString.length - 1) };
|
||||
}
|
||||
|
||||
if (argString === 'TRUE') {
|
||||
// NOTE: Boolean true
|
||||
return { type: 'T', value: true };
|
||||
}
|
||||
|
||||
if (argString === 'FALSE') {
|
||||
// NOTE: Boolean false
|
||||
return { type: 'F', value: false };
|
||||
}
|
||||
|
||||
// NOTE: string
|
||||
return { type: 'string', value: argString };
|
||||
});
|
||||
|
||||
return parsedArguments;
|
||||
}
|
||||
@@ -49,8 +49,8 @@ export const parseRundown = (data: Partial<DatabaseModel>): OntimeRundown => {
|
||||
|
||||
if (isOntimeEvent(event)) {
|
||||
if (event.linkStart) {
|
||||
const prevEvent = getLastEvent(rundown).lastEvent;
|
||||
event.linkStart = prevEvent.id;
|
||||
const prevId = getLastEvent(rundown).lastEvent?.id ?? null;
|
||||
event.linkStart = prevId;
|
||||
}
|
||||
newEvent = createEvent(event, eventIndex.toString());
|
||||
// skip if event is invalid
|
||||
|
||||
@@ -22,11 +22,12 @@ export function throttle<T extends any[], U>(cb: (...args: T) => U, delay: numbe
|
||||
return (...args: T) => {
|
||||
if (shouldWait) {
|
||||
waitingArgs = args;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
cb(...args);
|
||||
shouldWait = true;
|
||||
setTimeout(timeoutFunc, delay);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Copy Past', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create event
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByLabel('Cue', { exact: true }).click();
|
||||
await page.getByLabel('Cue', { exact: true }).fill('4');
|
||||
await page.getByLabel('Cue', { exact: true }).press('Enter');
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('block__title').click();
|
||||
await page.getByTestId('block__title').fill('test');
|
||||
await page.getByTestId('block__title').press('Enter');
|
||||
|
||||
//copy past below
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).click();
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+c');
|
||||
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+v');
|
||||
|
||||
//assert
|
||||
await expect(page.getByTestId('entry-2')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5');
|
||||
|
||||
//copy past above
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).click();
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+c');
|
||||
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+Shift+v');
|
||||
|
||||
//assert
|
||||
await expect(page.getByTestId('entry-2')).toBeVisible();
|
||||
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('4.1');
|
||||
});
|
||||
|
||||
test('Move', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
|
||||
//copy move down
|
||||
await page.getByTestId('entry-1').locator('#event-block').getByText('1').click();
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Control+ArrowDown');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('1');
|
||||
|
||||
//copy move up
|
||||
await page.getByTestId('entry-3').locator('#event-block').getByText('3').click();
|
||||
await page.getByTestId('entry-3').locator('#event-block div').filter({ hasText: '3' }).press('Alt+Control+ArrowUp');
|
||||
await page.getByTestId('entry-2').locator('div').filter({ hasText: /^3$/ }).press('Alt+Control+ArrowUp');
|
||||
await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('3');
|
||||
});
|
||||
|
||||
test('Add block', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('block__title').press('Escape');
|
||||
|
||||
//add block below
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+B');
|
||||
await expect(page.getByPlaceholder('Block title')).toBeVisible();
|
||||
|
||||
//add block above
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+B');
|
||||
await expect(page.getByTestId('entry-0').getByTestId('block__title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Add delay', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('block__title').press('Escape');
|
||||
|
||||
//add delay below
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+D');
|
||||
await expect(page.getByTestId('delay-input')).toBeVisible();
|
||||
|
||||
//add delay above
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+D');
|
||||
await expect(page.getByTestId('entry-0').getByTestId('delay-input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Add event', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await page.getByTestId('entry-1').click();
|
||||
await page.getByTestId('block__title').press('Escape');
|
||||
|
||||
//add event below
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+E');
|
||||
await expect(page.getByTestId('entry-2').locator('#event-block').getByText('2')).toBeVisible();
|
||||
|
||||
//add event above
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+E');
|
||||
await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('0.1');
|
||||
});
|
||||
|
||||
test('Delete event', async ({ page }) => {
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
|
||||
// clear rundown
|
||||
await page.goto('http://localhost:4001/rundown');
|
||||
await page.getByRole('button', { name: 'Clear rundown' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
|
||||
//create event
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
|
||||
//delete event
|
||||
await page.locator('#event-block div').filter({ hasText: '1' }).click();
|
||||
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Backspace');
|
||||
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
|
||||
});
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.0.4",
|
||||
"version": "3.2.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
@@ -41,15 +41,15 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/node": "^18.11.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-playwright": "^1.5.2",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"turbo": "^1.11.2",
|
||||
"typescript": "^5.4.3"
|
||||
},
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"eslint": "^8.53.0",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.56.0",
|
||||
"typescript": "^5.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export type OscSubscription = {
|
||||
id: string;
|
||||
cycle: TimerLifeCycleKey;
|
||||
address: string;
|
||||
payload: string;
|
||||
payload: string; // TODO: we should be using arguments to keep in line with protocol language
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum LogLevel {
|
||||
Info = 'INFO',
|
||||
Warn = 'WARN',
|
||||
Error = 'ERROR',
|
||||
Severe = 'SEVERE',
|
||||
}
|
||||
|
||||
export type Log = {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { MaybeNumber } from '../../index.js';
|
||||
import type { Playback } from './Playback.type.js';
|
||||
|
||||
export enum TimerPhase {
|
||||
None = 'none',
|
||||
Default = 'default',
|
||||
Warning = 'warning',
|
||||
Danger = 'danger',
|
||||
Overtime = 'overtime',
|
||||
Pending = 'pending', // used for waiting to roll
|
||||
}
|
||||
|
||||
export type TimerState = {
|
||||
addedTime: number; // time added by user, can be negative
|
||||
current: MaybeNumber; // running countdown
|
||||
@@ -8,6 +17,7 @@ export type TimerState = {
|
||||
elapsed: MaybeNumber; // elapsed time in current timer
|
||||
expectedFinish: MaybeNumber; // time we expect timer to finish
|
||||
finishedAt: MaybeNumber; // only if timer has already finished
|
||||
phase: TimerPhase;
|
||||
playback: Playback;
|
||||
secondaryTimer: MaybeNumber; // used for roll mode
|
||||
startedAt: MaybeNumber; // only if timer has already started
|
||||
|
||||
@@ -60,7 +60,7 @@ export type { Message, TimerMessage, MessageState } from './definitions/runtime/
|
||||
|
||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||
export type { TimerState } from './definitions/runtime/TimerState.type.js';
|
||||
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
|
||||
|
||||
// ---> Extra Timer
|
||||
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
export { parseUserTime } from './src/date-utils/parseUserTime.js';
|
||||
export { isAlphanumeric } from './src/regex-utils/isAlphanumeric.js';
|
||||
export { isColourHex } from './src/regex-utils/isColourHex.js';
|
||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||
|
||||
// helpers from externals
|
||||
export { deepmerge } from './src/externals/deepmerge.js';
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
"nanoid": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^v7.12.0",
|
||||
"@typescript-eslint/parser": "^7.12.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier": "^3.3.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type MaybeNumber = number | null;
|
||||
import type { MaybeNumber } from 'ontime-types';
|
||||
|
||||
export const MILLIS_PER_SECOND = 1000;
|
||||
export const MILLIS_PER_MINUTE = 1000 * 60;
|
||||
@@ -7,26 +7,60 @@ export const MILLIS_PER_HOUR = 1000 * 60 * 60;
|
||||
export const dayInMs = 86400000;
|
||||
export const maxDuration = dayInMs - MILLIS_PER_SECOND;
|
||||
|
||||
function convertMillis(millis: MaybeNumber, conversion: number) {
|
||||
/**
|
||||
* Utility converts milliseconds to a specific unit
|
||||
* @param millis
|
||||
* @param conversion
|
||||
* @returns
|
||||
*/
|
||||
function convertMillis(millis: MaybeNumber, conversion: number): number {
|
||||
if (!millis) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// for negative times, we want to round up
|
||||
if (millis < 0) {
|
||||
Math.ceil(millis / conversion);
|
||||
}
|
||||
return Math.floor(millis / conversion);
|
||||
}
|
||||
|
||||
export function millisToSeconds(millis: MaybeNumber) {
|
||||
/**
|
||||
* Converts value in milliseconds to seconds
|
||||
* @param millis
|
||||
* @returns
|
||||
*/
|
||||
export function millisToSeconds(millis: MaybeNumber): number {
|
||||
return convertMillis(millis, MILLIS_PER_SECOND);
|
||||
}
|
||||
|
||||
export function millisToMinutes(millis: MaybeNumber) {
|
||||
/**
|
||||
* Converts value in milliseconds to minutes
|
||||
* @param millis
|
||||
* @returns
|
||||
*/
|
||||
export function millisToMinutes(millis: MaybeNumber): number {
|
||||
return convertMillis(millis, MILLIS_PER_MINUTE);
|
||||
}
|
||||
|
||||
export function millisToHours(millis: MaybeNumber) {
|
||||
/**
|
||||
* Converts value in milliseconds to hours
|
||||
* @param millis
|
||||
* @returns
|
||||
*/
|
||||
export function millisToHours(millis: MaybeNumber): number {
|
||||
return convertMillis(millis, MILLIS_PER_HOUR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts value in seconds to minutes
|
||||
* @param seconds
|
||||
* @returns
|
||||
*/
|
||||
export function secondsToMinutes(seconds: number): number {
|
||||
return Math.floor(seconds / 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts value in seconds to hours
|
||||
* @param seconds
|
||||
* @returns
|
||||
*/
|
||||
export function secondsToHours(seconds: number): number {
|
||||
return Math.floor(seconds / 3600);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,29 @@ describe('millisToString()', () => {
|
||||
expect(millisToString(0)).toBe('00:00:00');
|
||||
});
|
||||
|
||||
it('shows negative timers', () => {
|
||||
test('negative times are rounded up', () => {
|
||||
const testScenarios = [
|
||||
{ millis: -300, expected: '-00:00:00' },
|
||||
{ millis: 300, expected: '00:00:00' },
|
||||
{ millis: -300, expected: '-00:00:01' },
|
||||
{ millis: 1000, expected: '00:00:01' },
|
||||
{ millis: -1000, expected: '-00:00:01' },
|
||||
{ millis: -1500, expected: '-00:00:01' },
|
||||
{ millis: 1500, expected: '00:00:01' },
|
||||
{ millis: -1500, expected: '-00:00:02' },
|
||||
{ millis: 60000 - 1, expected: '00:00:59' },
|
||||
{ millis: -(60000 - 1), expected: '-00:01:00' },
|
||||
{ millis: 60000, expected: '00:01:00' },
|
||||
{ millis: -60000, expected: '-00:01:00' },
|
||||
{ millis: 600000, expected: '00:10:00' },
|
||||
{ millis: -600000, expected: '-00:10:00' },
|
||||
{ millis: 3600000, expected: '01:00:00' },
|
||||
{ millis: -3600000, expected: '-01:00:00' },
|
||||
{ millis: 36000000, expected: '10:00:00' },
|
||||
{ millis: -36000000, expected: '-10:00:00' },
|
||||
{ millis: 86399000, expected: '23:59:59' },
|
||||
{ millis: -86399000, expected: '-23:59:59' },
|
||||
{ millis: 86400000, expected: '24:00:00' },
|
||||
{ millis: -86400000, expected: '-24:00:00' },
|
||||
{ millis: 86401000, expected: '24:00:01' },
|
||||
{ millis: -86401000, expected: '-24:00:01' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { millisToHours, millisToMinutes, millisToSeconds } from './conversionUtils.js';
|
||||
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
||||
|
||||
function pad(val: number): string {
|
||||
return String(val).padStart(2, '0');
|
||||
@@ -21,12 +21,13 @@ export function millisToString(millis?: MaybeNumber, options?: FormatOptions): s
|
||||
return options?.fallback ?? '...';
|
||||
}
|
||||
|
||||
const absoluteMillis = Math.abs(millis);
|
||||
const seconds = millisToSeconds(absoluteMillis) % 60;
|
||||
const minutes = millisToMinutes(absoluteMillis) % 60;
|
||||
const hours = millisToHours(absoluteMillis);
|
||||
const isNegative = millis < 0;
|
||||
|
||||
const totalSeconds = Math.abs(millisToSeconds(millis));
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = secondsToMinutes(totalSeconds) % 60;
|
||||
const hours = secondsToHours(totalSeconds);
|
||||
|
||||
return `${isNegative ? '-' : ''}${[hours, minutes, seconds].map(pad).join(':')}`;
|
||||
}
|
||||
|
||||
@@ -71,14 +72,14 @@ export function removeSeconds(timer: string): string {
|
||||
|
||||
/**
|
||||
* Formats a given date into a custom string format based on UTC time.
|
||||
*
|
||||
*
|
||||
* @param millis - The number of milliseconds.
|
||||
* @param format - A string specifying the desired output format.
|
||||
* @param format - A string specifying the desired output format.
|
||||
* For example, 'ss' will format the millis as '07' seconds.
|
||||
*
|
||||
*
|
||||
* @returns The formatted date as a string according to the provided `format` string.
|
||||
* If input `millis` is smaller than zero, it returns undefined.
|
||||
*
|
||||
*
|
||||
*/
|
||||
export function formatFromMillis(millis: number, format: string): string | undefined {
|
||||
if (millis < 0) {
|
||||
@@ -94,21 +95,21 @@ export function formatFromMillis(millis: number, format: string): string | undef
|
||||
const secondPadded = date.getUTCSeconds().toString().padStart(2, '0');
|
||||
const second = date.getUTCSeconds().toString();
|
||||
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
|
||||
const hour12 = ((date.getUTCHours() % 12) || 12).toString();
|
||||
const hour12 = (date.getUTCHours() % 12 || 12).toString();
|
||||
const hour12Padded = hour12.padStart(2, '0');
|
||||
const amPm = date.getUTCHours() >= 12 ? 'PM' : 'AM';
|
||||
|
||||
const replacements: Record<string, string> = {
|
||||
'HH': hour24Padded,
|
||||
'H': hour24,
|
||||
'hh': hour12Padded,
|
||||
'h': hour12,
|
||||
'mm': minutePadded,
|
||||
'm': minute,
|
||||
'ss': secondPadded,
|
||||
's': second,
|
||||
'S': milliseconds,
|
||||
'a': amPm
|
||||
HH: hour24Padded,
|
||||
H: hour24,
|
||||
hh: hour12Padded,
|
||||
h: hour12,
|
||||
mm: minutePadded,
|
||||
m: minute,
|
||||
ss: secondPadded,
|
||||
s: second,
|
||||
S: milliseconds,
|
||||
a: amPm,
|
||||
};
|
||||
|
||||
return applyReplacements(format, replacements);
|
||||
@@ -123,7 +124,7 @@ export function formatFromMillis(millis: number, format: string): string | undef
|
||||
*/
|
||||
function applyReplacements(template: string, replacements: Record<string, string>): string {
|
||||
return Object.keys(replacements).reduce((result, token) => {
|
||||
const regex = new RegExp(`\\b${token}\\b`, 'g');
|
||||
return result.replace(regex, replacements[token]);
|
||||
const regex = new RegExp(`\\b${token}\\b`, 'g');
|
||||
return result.replace(regex, replacements[token]);
|
||||
}, template);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { splitWhitespace } from './splitWhitespace';
|
||||
|
||||
describe('test splitWhitespace() function', () => {
|
||||
it('empty string', () => {
|
||||
const test = '';
|
||||
expect(splitWhitespace(test)).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('just space', () => {
|
||||
const test = ' ';
|
||||
expect(splitWhitespace(test)).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('1 item', () => {
|
||||
const test = 'test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test']);
|
||||
});
|
||||
|
||||
it('2 items', () => {
|
||||
const test = 'test test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test']);
|
||||
});
|
||||
|
||||
it('2 items and quoted string', () => {
|
||||
const test = 'test test "more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
|
||||
});
|
||||
|
||||
it('2 sapces', () => {
|
||||
const test = 'test test "more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
|
||||
});
|
||||
|
||||
it('quotes without spaces', () => {
|
||||
const test = 'test test "moreTest"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"moreTest"']);
|
||||
});
|
||||
|
||||
it('escaped quotes', () => {
|
||||
const test = 'test test "more \\" test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more " test"']);
|
||||
});
|
||||
|
||||
it('missing end quotes', () => {
|
||||
const test = 'test test "more test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test']);
|
||||
});
|
||||
|
||||
it('missing start quotes', () => {
|
||||
const test = 'test test more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', 'more', 'test"']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const splitRegex = /\\?.|^$/g;
|
||||
|
||||
/**
|
||||
* adapted from {@link https://stackoverflow.com/questions/4031900/split-a-string-by-whitespace-keeping-quoted-segments-allowing-escaped-quotes this}
|
||||
* @param str string to split
|
||||
* @returns
|
||||
*/
|
||||
export const splitWhitespace = (str: string, keepQuotes = true): null | string[] => {
|
||||
const match = str.match(splitRegex);
|
||||
if (!match || match[0] == '') {
|
||||
return null;
|
||||
}
|
||||
const array = match
|
||||
.reduce(
|
||||
(accumulator, current) => {
|
||||
if (current === '"') {
|
||||
accumulator.inQuotes ^= 1;
|
||||
if (keepQuotes) {
|
||||
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
|
||||
}
|
||||
} else if (!accumulator.inQuotes && current === ' ') {
|
||||
accumulator.array.push('');
|
||||
} else {
|
||||
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{ array: [''], inQuotes: 0 },
|
||||
)
|
||||
.array.filter((value) => value != '');
|
||||
|
||||
if (!array.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array;
|
||||
};
|
||||
Generated
+239
-412
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"dist-win": {},
|
||||
"dist-mac": {},
|
||||
"dist-mac:local": {},
|
||||
"dist-linux": {},
|
||||
"cleanup": {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user