Compare commits

...

22 Commits

Author SHA1 Message Date
Luke Stein cf8c8d4b2e bump version to 3.2.0 2024-06-13 22:46:32 +02:00
Alex Christoffer Rasmussen 9e63261b35 None colour from API (#1063) 2024-06-12 21:31:08 +02:00
Alex Christoffer Rasmussen b1838a5e9a display multiple custom fields in operator view (#1001)
---------

Co-authored-by: Carlos Valente <carlosvalente@pm.me>
2024-06-12 14:25:26 +02:00
Alex Christoffer Rasmussen 9d7b5568e4 use port 3000 in dev mode (#1065) 2024-06-11 13:37:10 +02:00
Alex Christoffer Rasmussen 657aed1244 Fix coerce enum (#1064) 2024-06-11 13:03:34 +02:00
Carlos Valente 13c49f3652 chore: improve dev scripts 2024-06-10 18:26:18 +02:00
Carlos Valente 86b4ddadb5 chore: prepare log levels 2024-06-10 18:26:18 +02:00
Carlos Valente e9f806b88a style: organise console levels and semantics 2024-06-10 18:26:18 +02:00
Carlos Valente 4f672670ed chore: document aux functions 2024-06-10 18:26:18 +02:00
Carlos Valente 8742b790f3 refactor: escalate severe to electron 2024-06-10 18:26:18 +02:00
Carlos Valente d41cd78f2e refactor: add severe log level 2024-06-10 18:26:18 +02:00
Carlos Valente 715600a514 refactor: improve console visibility 2024-06-10 18:26:18 +02:00
Carlos Valente b99cf5f255 refactor: improve error states in spreadsheet flow 2024-06-09 22:04:41 +02:00
Carlos Valente 9a9a0eb7cf refactor: use timer phase while waiting 2024-06-08 13:47:26 +02:00
Carlos Valente 4bf2422ebc refactor: show next when waiting to roll 2024-06-08 13:47:26 +02:00
Michael Foster add769fe1e fix for MultiPartProgressBar styling (#1022) 2024-06-07 21:08:17 +02:00
Carlos Valente 3af4c64ff3 chore: upgrade lint dependencies 2024-06-07 20:15:43 +02:00
Alex Christoffer Rasmussen f405e65c55 Custom for timer title (#1055) 2024-06-07 15:24:29 +02:00
Alex Christoffer Rasmussen 0e32b3a3af Fix: link events in sheet when there is nothing to link (#1053)
* create failing tests

* fix missing optional chaining
2024-06-06 22:38:52 +02:00
Alex Christoffer Rasmussen c2fa115946 Validate project filename (#1046) 2024-06-06 22:37:41 +02:00
jwetzell edb48a2fc7 consider custom fields when loading operator main field (#1049) 2024-06-06 21:57:50 +02:00
Alex Christoffer Rasmussen 6f7846bbf7 fix and test only space osc payload (#1052) 2024-06-06 11:19:12 +02:00
50 changed files with 1007 additions and 662 deletions
+3
View File
@@ -81,6 +81,9 @@ From the project root, run the following commands
The build distribution assets will be at `.apps/electron/dist` 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 ## DOCKER
Ontime provides a docker-compose file to aid with building and running docker images. Ontime provides a docker-compose file to aid with building and running docker images.
+8 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime-ui", "name": "ontime-ui",
"version": "3.1.1", "version": "3.2.0",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
@@ -70,13 +70,13 @@
"@types/react": "^18.0.26", "@types/react": "^18.0.26",
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^27.6.0", "eslint-plugin-jest": "^28.6.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.32.0", "eslint-plugin-react": "^7.32.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
@@ -84,7 +84,7 @@
"jsdom": "^21.1.0", "jsdom": "^21.1.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.3.1",
"sass": "^1.57.1", "sass": "^1.57.1",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vite": "^5.2.11", "vite": "^5.2.11",
+3 -3
View File
@@ -11,8 +11,8 @@ const dbPath = `${apiEntryUrl}/db`;
/** /**
* HTTP request to the current DB * HTTP request to the current DB
*/ */
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> { async function getDb(filename: string): Promise<AxiosResponse<DatabaseModel>> {
return axios.post(`${dbPath}/download/`, { fileName }); 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 url = `${dbPath}/${filename}/rename`;
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
const res = await axios.put(decodedUrl, { const res = await axios.put(decodedUrl, {
newFilename, filename: newFilename,
}); });
return res.data; return res.data;
} }
@@ -7,10 +7,6 @@ $progress-bar-br: 3px;
position: relative; position: relative;
width: 100%; width: 100%;
height: $progress-bar-size; 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 { &--hidden {
display: none; 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 { .multiprogress-bar__indicator {
position: absolute; position: absolute;
height: inherit; inset: 0;
background-color: black; 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; opacity: 0.8;
transition: 1s linear; transition: 1s linear;
transition-property: width; transition-property: width;
right: 0;
.multiprogress-bar--ignore-css-override & {
background-color: $ui-black;
}
} }
.multiprogress-bar__bg-normal { .multiprogress-bar__bg-normal {
position: absolute; flex: 1;
height: inherit;
right: 0;
width: 100%;
}
.multiprogress-bar__bg-warning {
position: absolute;
height: inherit;
right: 0;
} }
.multiprogress-bar__bg-danger { .multiprogress-bar__bg-danger {
position: absolute; flex-shrink: 0;
height: inherit; }
right: 0;
}
@@ -13,31 +13,51 @@ interface MultiPartProgressBar {
danger?: MaybeNumber; danger?: MaybeNumber;
dangerColor: string; dangerColor: string;
hidden?: boolean; hidden?: boolean;
ignoreCssOverride?: boolean;
className?: string; className?: string;
} }
export default function MultiPartProgressBar(props: MultiPartProgressBar) { 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 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 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 ( 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 && ( {now !== null && (
<> <>
<div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} /> <div className='multiprogress-bar__bg'>
<div <div className='multiprogress-bar__bg-normal' style={{ backgroundColor: normalColor }} />
className='multiprogress-bar__bg-warning' <div
style={{ width: `${warningWidth}%`, backgroundColor: warningColor }} className='multiprogress-bar__bg-warning'
/> style={{ width: `${warningWidth}%`, backgroundColor: warningColor }}
<div />
className='multiprogress-bar__bg-danger' <div
style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }} className='multiprogress-bar__bg-danger'
/> style={{ width: `${dangerWidth}%`, backgroundColor: dangerColor }}
<div className='multiprogress-bar__indicator' style={{ width: `${percentRemaining}%` }} /> />
</div>
<div className='multiprogress-bar__indicator'>
<div className='multiprogress-bar__indicator-bar' style={{ width: `${percentRemaining}%` }} />
</div>
</> </>
)} )}
</div> </div>
@@ -1,5 +1,18 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom'; 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'; 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') { if (type === 'boolean') {
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue; const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
@@ -70,3 +87,47 @@ export default function ParamInput(props: EditFormInputProps) {
</InputGroup> </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 { CustomFields } from 'ontime-types';
import { capitaliseFirstLetter } from '../../../features/viewers/common/viewUtils'; import { type ParamField } from './types';
import { ParamField } from './types';
const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => { const makeOptionsFromCustomFields = (customFields: CustomFields, additionalOptions?: Record<string, string>) => {
const customFieldOptions = Object.keys(customFields).reduce((acc, key) => { const customFieldOptions = Object.entries(customFields).reduce((acc, [key, value]) => {
return { ...acc, [`custom-${key}`]: `Custom: ${capitaliseFirstLetter(key)}` }; return { ...acc, [`custom-${key}`]: `Custom: ${value.label}` };
}, additionalOptions ?? {}); }, additionalOptions ?? {});
return customFieldOptions; return customFieldOptions;
}; };
@@ -111,6 +109,7 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
]; ];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => { export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const mainOptions = makeOptionsFromCustomFields(customFields, { title: 'Title' });
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' }); const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [ return [
getTimeOption(timeFormat), getTimeOption(timeFormat),
@@ -123,6 +122,14 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
type: 'boolean', type: 'boolean',
defaultValue: false, 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', id: 'secondary-src',
title: 'Secondary text', title: 'Secondary text',
@@ -455,8 +462,8 @@ export const getStudioClockOptions = (timeFormat: string): ParamField[] => [
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => { export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ParamField[] => {
const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' }); const fieldOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
const customFieldSelect = Object.keys(customFields).reduce((acc, key) => { const customFieldSelect = Object.entries(customFields).reduce((acc, [key, field]) => {
return { ...acc, [key]: `Custom: ${capitaliseFirstLetter(key)}` }; return { ...acc, [key]: { value: key, label: field.label, colour: field.colour } };
}, {}); }, {});
return [ return [
@@ -488,9 +495,8 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
id: 'subscribe', id: 'subscribe',
title: 'Highlight Field', title: 'Highlight Field',
description: 'Choose a custom field to highlight', description: 'Choose a custom field to highlight',
type: 'option', type: 'multi-option',
values: customFieldSelect, values: customFieldSelect,
defaultValue: '',
}, },
{ {
id: 'shouldEdit', id: 'shouldEdit',
@@ -9,8 +9,15 @@ type OptionsField = {
values: Record<string, string>; values: Record<string, string>;
defaultValue?: 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 StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string }; type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
type BooleanField = { type: 'boolean'; defaultValue: boolean }; type BooleanField = { type: 'boolean'; defaultValue: boolean };
export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField); export type ParamField = BaseField & (StringField | BooleanField | NumberField | OptionsField | MultiOptionsField);
@@ -67,7 +67,7 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
return ( return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}> <form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<div className={style.column}> <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>} {errors.label && <Panel.Error>{errors.label.message}</Panel.Error>}
<Input <Input
{...register('label', { {...register('label', {
@@ -50,7 +50,8 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
const handleSubmitCreate = async (values: ProjectCreateFormValues) => { const handleSubmitCreate = async (values: ProjectCreateFormValues) => {
try { try {
setError(null); setError(null);
const filename = values.title?.trim();
const filename = values.title ?? 'untitled';
await createProject({ await createProject({
...values, ...values,
@@ -1,5 +1,5 @@
.uploadSection, .uploadSection,
.successSection { .finishSection {
margin-top: 1rem; margin-top: 1rem;
display: flex; display: flex;
padding: 3rem 1rem; padding: 3rem 1rem;
@@ -15,12 +15,18 @@
gap: 2rem; gap: 2rem;
} }
.successSection { .finishSection {
color: $green-500;
font-size: 1.5rem; font-size: 1.5rem;
text-align: center; text-align: center;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
.error {
color: $red-500;
}
.success {
color: $green-500;
}
} }
.buttonRow { .buttonRow {
@@ -134,11 +134,20 @@ export default function SourcesPanel() {
await exportRundown(sheetId, importMap); await exportRundown(sheetId, importMap);
}; };
const resetFlow = () => {
setImportFlow('none');
setRundown(null);
setHasFile('none');
setWorksheets(null);
setCustomFields(null);
setError('');
};
const isExcelFlow = importFlow === 'excel'; const isExcelFlow = importFlow === 'excel';
const isGSheetFlow = importFlow === 'gsheet'; const isGSheetFlow = importFlow === 'gsheet';
const isAuthenticated = authenticationStatus === 'authenticated'; const isAuthenticated = authenticationStatus === 'authenticated';
const showInput = importFlow === 'none'; const showInput = importFlow === 'none';
const showSuccess = importFlow === 'finished'; const showCompleted = importFlow === 'finished';
const showAuth = isGSheetFlow && !isAuthenticated; const showAuth = isGSheetFlow && !isAuthenticated;
const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done'); const showImportMap = (isGSheetFlow && isAuthenticated) || (isExcelFlow && hasFile === 'done');
const showReview = rundown !== null && customFields !== null; const showReview = rundown !== null && customFields !== null;
@@ -189,10 +198,18 @@ export default function SourcesPanel() {
</div> </div>
</> </>
)} )}
{showSuccess && ( {showCompleted && (
<div className={style.successSection}> <div className={style.finishSection}>
<span>Import successful</span> {error ? (
<Button variant='ontime-filled' size='sm' onClick={() => setImportFlow('none')}> <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 Return
</Button> </Button>
</div> </div>
@@ -200,6 +217,7 @@ export default function SourcesPanel() {
{showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />} {showAuth && <GSheetSetup onCancel={cancelGSheetFlow} />}
{showImportMap && !showReview && ( {showImportMap && !showReview && (
<ImportMapForm <ImportMapForm
hasErrors={Boolean(error)}
isSpreadsheet={isExcelFlow} isSpreadsheet={isExcelFlow}
onCancel={cancelImportMap} onCancel={cancelImportMap}
onSubmitExport={handleSubmitExport} onSubmitExport={handleSubmitExport}
@@ -15,14 +15,15 @@ import { convertToImportMap, getPersistedOptions, NamedImportMap, persistImportM
import style from '../SourcesPanel.module.scss'; import style from '../SourcesPanel.module.scss';
interface ImportMapFormProps { interface ImportMapFormProps {
isSpreadsheet?: boolean; hasErrors: boolean;
isSpreadsheet: boolean;
onCancel: () => void; onCancel: () => void;
onSubmitExport: (importMap: ImportMap) => Promise<void>; onSubmitExport: (importMap: ImportMap) => Promise<void>;
onSubmitImport: (importMap: ImportMap) => Promise<void>; onSubmitImport: (importMap: ImportMap) => Promise<void>;
} }
export default function ImportMapForm(props: ImportMapFormProps) { export default function ImportMapForm(props: ImportMapFormProps) {
const { isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props; const { hasErrors, isSpreadsheet, onCancel, onSubmitExport, onSubmitImport } = props;
const namedImportMap = getPersistedOptions(); const namedImportMap = getPersistedOptions();
const { revoke } = useGoogleSheet(); const { revoke } = useGoogleSheet();
const { const {
@@ -78,7 +79,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
const isLoading = Boolean(loading); const isLoading = Boolean(loading);
const canSubmitSpreadsheet = isSpreadsheet && !isLoading; const canSubmitSpreadsheet = isSpreadsheet && !isLoading;
const canSubmitGSheet = !isLoading; const canSubmitGSheet = !isLoading;
const canSubmit = isValid && (canSubmitSpreadsheet || canSubmitGSheet); const canSubmit = !hasErrors && isValid && (canSubmitSpreadsheet || canSubmitGSheet);
return ( return (
<Panel.Section as='form' id='import-map'> <Panel.Section as='form' id='import-map'>
@@ -43,7 +43,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const finish = millisToString(expectedFinish); const finish = millisToString(expectedFinish);
const isRolling = playback === Playback.Roll; const isRolling = playback === Playback.Roll;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null; const isWaiting = timer.phase === TimerPhase.Pending;
const isOvertime = timer.phase === TimerPhase.Overtime; const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime); const hasAddedTime = Boolean(timer.addedTime);
@@ -19,6 +19,7 @@ export default function CuesheetProgress() {
danger={timeDanger} danger={timeDanger}
dangerColor={data!.dangerColor} dangerColor={data!.dangerColor}
className={styles.progressOverride} className={styles.progressOverride}
ignoreCssOverride
/> />
); );
} }
+21 -21
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; 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 { getFirstEventNormal, getLastEventNormal } from 'ontime-utils';
import Empty from '../../common/components/state/Empty'; import Empty from '../../common/components/state/Empty';
@@ -27,11 +27,9 @@ import style from './Operator.module.scss';
const selectedOffset = 50; const selectedOffset = 50;
export type Subscribed = { id: string; label: string; colour: string; value: string }[];
type TitleFields = Pick<OntimeEvent, 'title'>; type TitleFields = Pick<OntimeEvent, 'title'>;
export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { fieldLabel?: string; fieldValue: string }; export type EditEvent = Pick<OntimeEvent, 'id' | 'cue'> & { subscriptions: Subscribed };
export type PartialEdit = EditEvent & {
field: keyof CustomFields;
};
export default function Operator() { export default function Operator() {
const { data, status } = useRundown(); const { data, status } = useRundown();
@@ -45,7 +43,7 @@ export default function Operator() {
const { data: settings } = useSettings(); const { data: settings } = useSettings();
const [showEditPrompt, setShowEditPrompt] = useState(false); 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 [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null); const selectedRef = useRef<HTMLDivElement | null>(null);
@@ -102,16 +100,9 @@ export default function Operator() {
debouncedHandleScroll(); debouncedHandleScroll();
}; };
const handleEdit = useCallback( const handleEdit = useCallback((event: EditEvent) => {
(event: EditEvent) => { setEditEvent({ ...event });
const field = searchParams.get('subscribe') as keyof CustomField | null; }, []);
if (field) {
setEditEvent({ ...event, field });
}
},
[searchParams],
);
const missingData = !data || !customFields || !projectData; const missingData = !data || !customFields || !projectData;
const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending'; const isLoading = status === 'pending' || customFieldStatus === 'pending' || projectDataStatus === 'pending';
@@ -122,8 +113,13 @@ export default function Operator() {
// get fields which the user subscribed to // get fields which the user subscribed to
const shouldEdit = searchParams.get('shouldEdit'); 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 main = searchParams.get('main') as keyof TitleFields | null;
const secondary = searchParams.get('secondary'); const secondary = searchParams.get('secondary');
@@ -171,9 +167,14 @@ export default function Operator() {
return null; 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 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 ( return (
<OperatorEvent <OperatorEvent
@@ -189,7 +190,6 @@ export default function Operator() {
delay={entry.delay} delay={entry.delay}
isSelected={isSelected} isSelected={isSelected}
subscribed={subscribedData} subscribed={subscribedData}
subscribeLabel={subscribe}
isPast={isPast} isPast={isPast}
selectedRef={isSelected ? selectedRef : undefined} selectedRef={isSelected ? selectedRef : undefined}
onLongPress={canEdit ? handleEdit : () => undefined} onLongPress={canEdit ? handleEdit : () => undefined}
@@ -1,48 +1,69 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Button, Textarea } from '@chakra-ui/react'; import { Button, Textarea } from '@chakra-ui/react';
import { OntimeEvent } from 'ontime-types';
import { useEventAction } from '../../../common/hooks/useEventAction'; import { useEventAction } from '../../../common/hooks/useEventAction';
import type { PartialEdit } from '../Operator'; import type { EditEvent } from '../Operator';
import style from './EditModal.module.scss'; import style from './EditModal.module.scss';
interface EditModalProps { interface EditModalProps {
event: PartialEdit; event: EditEvent;
onClose: () => void; onClose: () => void;
} }
export default function EditModal(props: EditModalProps) { export default function EditModal(props: EditModalProps) {
const { event, onClose } = props; const { event, onClose } = props;
const { updateCustomField } = useEventAction(); const { updateEvent } = useEventAction();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const inputRef = useRef<HTMLTextAreaElement[]>(new Array<HTMLTextAreaElement>());
const handleSave = async () => { const handleSave = async () => {
if (!inputRef.current) return;
setLoading(true); setLoading(true);
const newValue = inputRef.current?.value;
if (newValue === undefined) { const patchObject: Partial<OntimeEvent> = { id: event.id };
return;
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); setLoading(false);
onClose(); onClose();
}; };
const fieldLabel = event?.fieldLabel ?? event.field;
return ( return (
<div className={style.editModal}> <div className={style.editModal}>
<div>{`Editing field ${fieldLabel} in cue ${event.cue}`}</div> <div>{`Editing fields in cue ${event.cue}`}</div>
<Textarea {event.subscriptions.map((field) => {
ref={inputRef} return (
variant='ontime-filled' <div key={field.label}>
placeholder={`Add value for ${fieldLabel} field`} <label>{field.label}</label>
defaultValue={event.fieldValue} <Textarea
isDisabled={loading} ref={(element) => {
resize='none' 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}> <div className={style.buttonRow}>
<Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}> <Button variant='ontime-subtle' onClick={onClose} isDisabled={loading}>
Cancel Cancel
@@ -20,9 +20,9 @@
grid-template-rows: auto auto auto; grid-template-rows: auto auto auto;
column-gap: 0.5rem; column-gap: 0.5rem;
grid-template-areas: grid-template-areas:
"binder main schedule" 'binder main schedule'
"binder secondary running" 'binder secondary running'
"binder fields fields"; 'binder fields fields';
&.subscribed { &.subscribed {
background-color: $gray-1250; background-color: $gray-1250;
@@ -93,16 +93,25 @@
font-weight: 400; font-weight: 400;
color: $ui-black; color: $ui-black;
margin: 0.25rem 0; margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
.field { .field {
font-weight: 600; font-weight: 600;
padding: 0 0.25rem; padding: 0 0.25rem;
background-color: var(--operator-highlight-override, $orange-600); margin-right: 0.25rem;
margin-right: 0.5rem; }
.noColour {
outline: 0.15rem solid var(--operator-highlight-override, $ui-white);
outline-offset: -0.15rem;
padding-right: 0.3rem;
color: $ui-white;
} }
.value { .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 { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import ClockTime from '../../viewers/common/clock-time/ClockTime'; import ClockTime from '../../viewers/common/clock-time/ClockTime';
import RunningTime from '../../viewers/common/running-time/RunningTime'; 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'; import style from './OperatorEvent.module.scss';
@@ -21,8 +21,7 @@ interface OperatorEventProps {
duration: number; duration: number;
delay?: number; delay?: number;
isSelected: boolean; isSelected: boolean;
subscribed?: string; subscribed: Subscribed | null;
subscribeLabel: string;
isPast: boolean; isPast: boolean;
selectedRef?: RefObject<HTMLDivElement>; selectedRef?: RefObject<HTMLDivElement>;
onLongPress: (event: EditEvent) => void; onLongPress: (event: EditEvent) => void;
@@ -47,7 +46,6 @@ function OperatorEvent(props: OperatorEventProps) {
delay, delay,
isSelected, isSelected,
subscribed, subscribed,
subscribeLabel: subscribedAlias,
isPast, isPast,
selectedRef, selectedRef,
onLongPress, onLongPress,
@@ -56,7 +54,9 @@ function OperatorEvent(props: OperatorEventProps) {
const handleLongPress = (event?: SyntheticEvent) => { const handleLongPress = (event?: SyntheticEvent) => {
// we dont have an event out of useLongPress // we dont have an event out of useLongPress
event?.preventDefault(); event?.preventDefault();
onLongPress({ id, cue, fieldLabel: subscribedAlias, fieldValue: subscribed ?? '' }); if (subscribed) {
onLongPress({ id, cue, subscriptions: subscribed });
}
}; };
const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 }); const mouseHandlers = useLongPress(handleLongPress, { threshold: 800 });
@@ -89,12 +89,22 @@ function OperatorEvent(props: OperatorEventProps) {
</span> </span>
<div className={style.fields}> <div className={style.fields}>
{subscribed && ( {subscribed &&
<> subscribed
<span className={style.field}>{subscribedAlias}</span> .filter((field) => field.value)
<span className={style.value}>{subscribed}</span> .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>
</div> </div>
); );
@@ -105,5 +105,5 @@
} }
.progressOverride { .progressOverride {
border-radius: 0; --progress-bar-br: 0;
} }
@@ -24,6 +24,7 @@ export default function StatusBarProgress(props: StatusBarProgressProps) {
danger={timeDanger} danger={timeDanger}
dangerColor={viewSettings.dangerColor} dangerColor={viewSettings.dangerColor}
className={styles.progressOverride} className={styles.progressOverride}
ignoreCssOverride
/> />
); );
} }
@@ -53,10 +53,6 @@ export function getPropertyValue(event: OntimeEvent | null, property: MaybeStrin
return event[property as keyof OntimeEvent] as string; return event[property as keyof OntimeEvent] as string;
} }
export function capitaliseFirstLetter(string: string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
type FormattingOptions = { type FormattingOptions = {
removeSeconds: boolean; removeSeconds: boolean;
removeLeadingZero: boolean; removeLeadingZero: boolean;
@@ -106,6 +106,10 @@ export default function Timer(props: TimerProps) {
const secondaryTextNow = getPropertyValue(eventNow, secondarySource); const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, 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 showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause; const isPlaying = time.playback !== Playback.Pause;
@@ -215,7 +219,7 @@ export default function Timer(props: TimerProps) {
animate='visible' animate='visible'
exit='exit' exit='exit'
> >
<TitleCard label='now' title={eventNow.title} secondary={secondaryTextNow} /> <TitleCard label='now' title={mainFieldNow} secondary={secondaryTextNow} />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -230,7 +234,7 @@ export default function Timer(props: TimerProps) {
animate='visible' animate='visible'
exit='exit' exit='exit'
> >
<TitleCard label='next' title={eventNext.title} secondary={secondaryTextNext} /> <TitleCard label='next' title={mainFieldNext} secondary={secondaryTextNext} />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
+1 -1
View File
@@ -3,7 +3,7 @@ module.exports = {
shutdownCode: 99, shutdownCode: 99,
}, },
reactAppUrl: { reactAppUrl: {
development: (port = 4001) => `http://localhost:${port}`, development: (port = 3000) => `http://localhost:${port}`,
production: (port = 4001) => `http://localhost:${port}`, production: (port = 4001) => `http://localhost:${port}`,
}, },
server: { server: {
+40 -9
View File
@@ -30,6 +30,10 @@ let win;
let splash; let splash;
let tray = null; 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() { async function startBackend() {
// in dev mode, we expect both UI and server to be running // in dev mode, we expect both UI and server to be running
if (!isProduction) { if (!isProduction) {
@@ -41,7 +45,7 @@ async function startBackend() {
await initAssets(); await initAssets();
const result = await startServer(); const result = await startServer(escalateError);
loaded = result.message; loaded = result.message;
await startIntegrations(); await startIntegrations();
@@ -62,6 +66,9 @@ function showNotification(title, text) {
}).show(); }).show();
} }
/**
* Terminate node service and close electron app
*/
function appShutdown() { function appShutdown() {
// terminate node service // terminate node service
(async () => { (async () => {
@@ -76,16 +83,30 @@ function appShutdown() {
app.quit(); app.quit();
} }
/**
* Sets Ontime window in focus
*/
function bringToFront() { function bringToFront() {
win.show(); win.show();
win.focus(); win.focus();
} }
/**
* Coordinates the shutdown process
*/
function askToQuit() { function askToQuit() {
bringToFront(); bringToFront();
win.send('user-request-shutdown'); 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 // Ensure there isn't another instance of the app running already
const lock = app.requestSingleInstanceLock(); const lock = app.requestSingleInstanceLock();
if (!lock) { if (!lock) {
@@ -103,6 +124,9 @@ if (!lock) {
}); });
} }
/**
* Coordinates creation of electron windows (splash and main)
*/
function createWindow() { function createWindow() {
splash = new BrowserWindow({ splash = new BrowserWindow({
width: 333, width: 333,
@@ -191,12 +215,16 @@ app.whenReady().then(() => {
console.log('ERROR: Ontime failed to start', error); console.log('ERROR: Ontime failed to start', error);
}); });
// recreate window if no others open /**
* recreate window if no others open
*/
app.on('activate', () => { app.on('activate', () => {
win.show(); win.show();
}); });
// Hide on close /**
* Hide on close
*/
win.on('close', function (event) { win.on('close', function (event) {
event.preventDefault(); event.preventDefault();
if (!isQuitting) { if (!isQuitting) {
@@ -213,12 +241,11 @@ app.whenReady().then(() => {
const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit); const trayMenuTemplate = getTrayMenu(bringToFront, askToQuit);
const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate); const trayContextMenu = Menu.buildFromTemplate(trayMenuTemplate);
tray.setContextMenu(trayContextMenu); tray.setContextMenu(trayContextMenu);
}); });
/**
// unregister shortcuts before quitting * Unregister shortcuts before quitting
*/
app.once('will-quit', () => { app.once('will-quit', () => {
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
}); });
@@ -235,7 +262,9 @@ ipcMain.on('shutdown', () => {
appShutdown(); appShutdown();
}); });
// Window manipulation /**
* Handles requests to set window properties
*/
ipcMain.on('set-window', (event, arg) => { ipcMain.on('set-window', (event, arg) => {
switch (arg) { switch (arg) {
case 'show-dev': 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) => { ipcMain.on('send-to-link', (event, arg) => {
shell.openExternal(arg); shell.openExternal(arg);
}); });
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.1.1", "version": "3.2.0",
"author": "Carlos Valente", "author": "Carlos Valente",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime", "repository": "https://github.com/cpvalente/ontime",
@@ -14,17 +14,18 @@
"devDependencies": { "devDependencies": {
"electron": "^28.3.3", "electron": "^28.3.3",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"prettier": "^3.0.3" "prettier": "^3.0.3"
}, },
"scripts": { "scripts": {
"postinstall": "", "postinstall": "",
"lint": "eslint . --quiet", "lint": "eslint . --quiet",
"lint-staged": "eslint", "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-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac", "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", "dist-linux": "electron-builder --publish=never --x64 --linux",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" "cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
}, },
+7 -6
View File
@@ -2,7 +2,7 @@
"name": "ontime-server", "name": "ontime-server",
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
"version": "3.1.1", "version": "3.2.0",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
"@googleapis/sheets": "^5.0.5", "@googleapis/sheets": "^5.0.5",
@@ -19,6 +19,7 @@
"node-osc": "^9.0.2", "node-osc": "^9.0.2",
"node-xlsx": "^0.23.0", "node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*", "ontime-utils": "workspace:*",
"sanitize-filename": "^1.6.3",
"steno": "^3.1.0", "steno": "^3.1.0",
"ts-essentials": "^9.4.1", "ts-essentials": "^9.4.1",
"ws": "^8.13.0" "ws": "^8.13.0"
@@ -31,14 +32,14 @@
"@types/node-osc": "^6.0.2", "@types/node-osc": "^6.0.2",
"@types/websocket": "^1.0.5", "@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.12.0",
"esbuild": "^0.19.10", "esbuild": "^0.19.10",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.1.3",
"nodemon": "^2.0.20", "nodemon": "^2.0.20",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.3.1",
"server-timing": "^3.3.3", "server-timing": "^3.3.3",
"shx": "^0.3.4", "shx": "^0.3.4",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
+8 -21
View File
@@ -16,7 +16,6 @@ import { failEmptyObjects } from '../../utils/routerUtils.js';
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js'; import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
import * as projectService from '../../services/project-service/ProjectService.js'; import * as projectService from '../../services/project-service/ProjectService.js';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js'; import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js'; import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js'; import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
@@ -54,8 +53,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/ */
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) { export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try { try {
const originalFilename = ensureJsonExtension(req.body.title || 'Untitled'); const filename = generateUniqueFileName(resolveProjectsDirectory, req.body.filename);
const filename = generateUniqueFileName(resolveProjectsDirectory, originalFilename);
const errors = projectService.validateProjectFiles({ newFilename: filename }); const errors = projectService.validateProjectFiles({ newFilename: filename });
if (errors.length) { if (errors.length) {
@@ -71,7 +69,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
backstageInfo: req.body?.backstageInfo ?? '', backstageInfo: req.body?.backstageInfo ?? '',
}; };
projectService.createProjectFile(filename, newProjectData); await projectService.createProjectFile(filename, newProjectData);
res.status(200).send({ res.status(200).send({
filename, filename,
@@ -83,29 +81,18 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
} }
/** /**
* Utility function finds the correct project file to download * Allows downloading of project files
*/
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
*/ */
export async function projectDownload(req: Request, res: Response) { 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 // Check if the file exists before attempting to download
if (!existsSync(pathToFile)) { 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) { if (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
res.status(500).send({ message }); res.status(500).send({ message });
@@ -228,7 +215,7 @@ export async function duplicateProjectFile(req: Request, res: Response<MessageRe
*/ */
export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function renameProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try { try {
const { newFilename } = req.body; const { filename: newFilename } = req.body;
const { filename } = req.params; const { filename } = req.params;
const errors = projectService.validateProjectFiles({ filename, newFilename }); const errors = projectService.validateProjectFiles({ filename, newFilename });
+11 -14
View File
@@ -14,28 +14,25 @@ import {
} from './db.controller.js'; } from './db.controller.js';
import { uploadProjectFile } from './db.middleware.js'; import { uploadProjectFile } from './db.middleware.js';
import { import {
projectSanitiser, validateNewProject,
sanitizeProjectFilename, validatePatchProject,
validateDownloadProject, validateFilenameBody,
validateLoadProjectFile, validateFilenameParam,
validatePatchProjectFile,
validateProjectDuplicate,
validateProjectRename,
} from './db.validation.js'; } from './db.validation.js';
export const router = express.Router(); export const router = express.Router();
router.post('/download', validateDownloadProject, projectDownload); router.post('/download', validateFilenameBody, projectDownload);
router.post('/upload', uploadProjectFile, postProjectFile); router.post('/upload', uploadProjectFile, postProjectFile);
router.patch('/', validatePatchProjectFile, patchPartialProjectFile); router.patch('/', validatePatchProject, patchPartialProjectFile);
router.post('/new', projectSanitiser, createProjectFile); router.post('/new', validateFilenameBody, validateNewProject, createProjectFile);
router.get('/all', listProjects); router.get('/all', listProjects);
router.post('/load', validateLoadProjectFile, sanitizeProjectFilename, loadProject); router.post('/load', validateFilenameBody, loadProject);
router.post('/:filename/duplicate', validateProjectDuplicate, sanitizeProjectFilename, duplicateProjectFile); router.post('/:filename/duplicate', validateFilenameParam, validateFilenameBody, duplicateProjectFile);
router.put('/:filename/rename', validateProjectRename, sanitizeProjectFilename, renameProjectFile); router.put('/:filename/rename', validateFilenameParam, validateFilenameBody, renameProjectFile);
router.delete('/:filename', sanitizeProjectFilename, deleteProjectFile); router.delete('/:filename', validateFilenameParam, deleteProjectFile);
router.get('/info', getInfo); router.get('/info', getInfo);
+28 -60
View File
@@ -1,9 +1,12 @@
import { Request, Response, NextFunction } from 'express'; 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 { 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('title').optional().isString().trim(),
body('description').optional().isString().trim(), body('description').optional().isString().trim(),
body('publicUrl').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; * @description Validates request for pathing data in the project.
const { filename: projectName } = req.params; */
export const validatePatchProject = [
req.body.filename = ensureJsonExtension(filename);
req.body.newFilename = ensureJsonExtension(newFilename);
req.params.filename = ensureJsonExtension(projectName);
next();
};
export const validatePatchProjectFile = [
body('rundown').isArray().optional({ nullable: false }), body('rundown').isArray().optional({ nullable: false }),
body('project').isObject().optional({ nullable: false }), body('project').isObject().optional({ nullable: false }),
body('settings').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 = [ export const validateFilenameBody = [
body('filename').exists().withMessage('Filename is required').isString().withMessage('Filename must be a string'), body('filename')
(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')
.exists() .exists()
.withMessage('New project filename is required')
.isString() .isString()
.withMessage('New project filename must be a string') .trim()
.isLength({ min: 1, max: 255 }) .customSanitizer((input: string) => sanitize(input))
.withMessage('New project filename must be between 1 and 255 characters'), .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) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); 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 = [ export const validateFilenameParam = [
body('newFilename') param('filename')
.exists() .exists()
.withMessage('Duplicate project filename is required')
.isString() .isString()
.withMessage('Duplicate project filename must be a string') .trim()
.isLength({ min: 1, max: 255 }) .customSanitizer((input: string) => sanitize(input))
.withMessage('Duplicate project filename must be between 1 and 255 characters'), .withMessage('Failed to sanitize the filename')
.notEmpty()
(req: Request, res: Response, next: NextFunction) => { .withMessage('Filename was empty or contained only invalid characters')
const errors = validationResult(req); .customSanitizer((input: string) => ensureJsonExtension(input)),
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(),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
+27 -16
View File
@@ -20,6 +20,7 @@ import {
clearUploadfolder, clearUploadfolder,
} from './setup/index.js'; } from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight } from './utils/console.js';
// Import Routers // Import Routers
import { appRouter } from './api-data/index.js'; import { appRouter } from './api-data/index.js';
@@ -44,10 +45,13 @@ import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js'; import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js'; import { initRundown } from './services/rundown-service/RundownService.js';
import { generateCrashReport } from './utils/generateCrashReport.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 running in ${environment} environment`);
console.log(`Ontime directory at ${srcDirectory} `); console.log(`Ontime directory at ${srcDirectory} `);
console.log(`Ontime database at ${resolveDbPath}`); console.log(`Ontime database at ${resolveDbPath}`);
@@ -56,7 +60,7 @@ if (!isProduction) {
// Create express APP // Create express APP
const app = express(); const app = express();
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
// log more serever timings // log server timings to requests
app.use(serverTiming()); app.use(serverTiming());
} }
app.disable('x-powered-by'); app.disable('x-powered-by');
@@ -151,18 +155,16 @@ export const initAssets = async () => {
/** /**
* Starts servers * 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); checkStart(OntimeStartOrder.InitServer);
const { serverPort } = DataProvider.getSettings(); const { serverPort } = DataProvider.getSettings();
const returnMessage = `Ontime is listening on port ${serverPort}`;
expressServer = http.createServer(app); expressServer = http.createServer(app);
socket.init(expressServer); socket.init(expressServer);
logger.info(LogOrigin.Server, returnMessage);
/** /**
* Module initialises the services and provides initial payload for the store * 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 // initialise rundown service
const persistedRundown = DataProvider.getRundown(); const persistedRundown = DataProvider.getRundown();
const persistedCustomFields = DataProvider.getCustomFields(); const persistedCustomFields = DataProvider.getCustomFields();
@@ -200,7 +205,16 @@ export const startServer = async () => {
// eventStore set is a dependency of the services that publish to it // eventStore set is a dependency of the services that publish to it
messageService.init(eventStore.set.bind(eventStore)); 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 }; return { message: returnMessage, serverPort };
}; };
@@ -241,7 +255,7 @@ export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpS
* @return {Promise<void>} * @return {Promise<void>}
*/ */
export const shutdown = async (exitCode = 0) => { 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 // clear the restore file if it was a normal exit
// 0 means it was a SIGNAL // 0 means it was a SIGNAL
@@ -251,7 +265,6 @@ export const shutdown = async (exitCode = 0) => {
await restoreService.clear(); await restoreService.clear();
} }
// TODO: Clear token
expressServer?.close(); expressServer?.close();
runtimeService.shutdown(); runtimeService.shutdown();
integrationService.shutdown(); integrationService.shutdown();
@@ -260,19 +273,17 @@ export const shutdown = async (exitCode = 0) => {
process.exit(exitCode); 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) => { process.on('unhandledRejection', async (error) => {
console.error('Error: unhandled rejection', error);
generateCrashReport(error); generateCrashReport(error);
logger.error(LogOrigin.Server, `Error: unhandled rejection ${error}`); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1); await shutdown(1);
}); });
process.on('uncaughtException', async (error) => { process.on('uncaughtException', async (error) => {
console.error('Error: uncaught exception', error);
generateCrashReport(error); generateCrashReport(error);
logger.error(LogOrigin.Server, `Error: uncaught exception ${error}`); logger.crash(LogOrigin.Server, `Uncaught exception | ${error}`);
await shutdown(1); await shutdown(1);
}); });
+28 -4
View File
@@ -4,22 +4,33 @@ import { generateId, millisToString } from 'ontime-utils';
import { clock } from '../services/Clock.js'; import { clock } from '../services/Clock.js';
import { isProduction } from '../setup/index.js'; import { isProduction } from '../setup/index.js';
import { socket } from '../adapters/WebsocketAdapter.js'; import { socket } from '../adapters/WebsocketAdapter.js';
import { consoleSubdued, consoleRed } from '../utils/console.js';
class Logger { class Logger {
private queue: Log[]; private queue: Log[];
private escalateErrorFn: (error: string) => void | null;
private canLog = false;
constructor() { constructor() {
this.queue = []; this.queue = [];
this.escalateErrorFn = null;
this.canLog = !isProduction;
} }
/** /**
* Enabling setup logger after init * Enabling setup logger after init
*/ */
init() { init(escalateErrorFn: (error: string) => void) {
// flush logs from queue
this.queue.forEach((log) => { this.queue.forEach((log) => {
this._push(log); this._push(log);
}); });
this.queue = []; this.queue = [];
// we only get this when running in electron
if (escalateErrorFn) {
this.escalateErrorFn = escalateErrorFn;
}
} }
private addToQueue(log: Log) { private addToQueue(log: Log) {
@@ -34,8 +45,12 @@ class Logger {
* @param log * @param log
*/ */
private _push(log: Log) { private _push(log: Log) {
if (!isProduction) { if (this.canLog || log.level === LogLevel.Severe) {
console.log(`[${log.level}] \t ${log.origin} \t ${log.text}`); 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 { try {
@@ -92,11 +107,20 @@ class Logger {
this.emit(LogLevel.Error, origin, text); 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 logger
*/ */
shutdown() { shutdown() {
console.log('Shutting down logger');
this.queue = []; this.queue = [];
} }
} }
+10 -4
View File
@@ -1,16 +1,22 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { consoleHighlight, consoleRed } from './utils/console.js';
import { initAssets, startIntegrations, startServer } from './app.js'; import { initAssets, startIntegrations, startServer } from './app.js';
async function startOntime() { async function startOntime() {
try { try {
console.log('Request: Initialise assets...'); console.log('\n');
consoleHighlight('Request: Initialise assets...');
await initAssets(); await initAssets();
console.log('Request: Start server...');
console.log('\n');
consoleHighlight('Request: Start server...');
await startServer(); await startServer();
console.log('Request: Start integrations...');
console.log('\n');
consoleHighlight('Request: Start integrations...');
await startIntegrations(); await startIntegrations();
} catch (error) { } catch (error) {
console.log(`Request failed: ${error}`); consoleRed(`Request failed: ${error}`);
} }
} }
@@ -978,6 +978,32 @@ describe('getRollTimers()', () => {
expect(state).toStrictEqual(expected); 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', () => { it('handles roll that goes over midnight', () => {
const singleEventList: Partial<OntimeEvent>[] = [ const singleEventList: Partial<OntimeEvent>[] = [
{ {
@@ -1890,4 +1916,44 @@ describe('getTimerPhase()', () => {
const phase = getTimerPhase(state); const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending); 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);
});
}); });
@@ -116,6 +116,18 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
test: '"string with space and {{not.so.easy}}"', test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }], 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}}"', test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }], expect: [{ type: 'string', value: 'string with space and ' }],
+7 -7
View File
@@ -120,13 +120,13 @@ type RollTimers = {
* @param {number} timeNow - time now in ms * @param {number} timeNow - time now in ms
*/ */
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => { export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
let nowIndex: number | null = null; // index of event now let nowIndex: MaybeNumber = null; // index of event now
let nowId: string | null = null; // id of event now let nowId: MaybeString = null; // id of event now
let publicIndex: number | null = null; // index of public event now let publicIndex: MaybeNumber = null; // index of public event now
let nextIndex: number | null = null; // index of next event let nextIndex: MaybeNumber = null; // index of next event
let publicNextIndex: number | null = null; // index of next public event let publicNextIndex: MaybeNumber = null; // index of next public event
let timeToNext: number | null = null; // counter: time for next event let timeToNext: MaybeNumber = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event let publicTimeToNext: MaybeNumber = null; // counter: time for next public event
const hasLoaded = currentIndex !== null; const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === rundown.length - 1; const canFilter = hasLoaded && currentIndex === rundown.length - 1;
+4
View File
@@ -466,6 +466,10 @@ export function roll(rundown: OntimeEvent[]) {
current: endTime - runtimeState.clock, current: endTime - runtimeState.clock,
}); });
} else if (nextEvent) { } else if (nextEvent) {
if (nextEvent.isPublic) {
runtimeState.publicEventNext = nextEvent;
}
runtimeState.eventNext = nextEvent;
// account for day after // account for day after
const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart; const nextStart = nextEvent.timeStart < runtimeState.clock ? nextEvent.timeStart + dayInMs : nextEvent.timeStart;
// nothing now, but something coming up // nothing now, but something coming up
@@ -18,22 +18,29 @@ describe('parses a colour string that is', () => {
it('not a string', () => { it('not a string', () => {
expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received')); 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', () => { describe('match a string to an enum that is', () => {
enum testEnum { enum testEnum {
'abc', ABC = 'abc',
'def', DEF = 'def',
'ghi', GHI = 'ghi',
} }
it('valid key', () => { it('valid key', () => {
const key = coerceEnum<testEnum>('abc', testEnum); const key = coerceEnum<testEnum>('abc', testEnum);
expect(key).toBe('abc'); expect(key).toBe('abc');
}); });
it('invalid key', () => { it('invalid key', () => {
expect(() => coerceEnum('123', testEnum)).toThrowError(Error('Invalid value received')); expect(() => coerceEnum('123', testEnum)).toThrow();
}); });
it('invalid type', () => { it('invalid type', () => {
expect(() => coerceEnum(123, testEnum)).toThrowError(Error('Invalid value received')); expect(() => coerceEnum(123, testEnum)).toThrow();
}); });
}); });
@@ -25,6 +25,12 @@ describe('test stringToOSCArgs()', () => {
expect(stringToOSCArgs(test)).toStrictEqual(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', () => { it('keep other types in strings', () => {
const test = 'test "1111" "0.1111" "TRUE" "FALSE"'; const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
const expected = [ const expected = [
@@ -1,5 +1,21 @@
import { CustomFields, HttpSubscription, OscSubscription } from 'ontime-types'; import {
import { sanitiseCustomFields, sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../parserFunctions.js'; CustomFields,
DatabaseModel,
EndAction,
HttpSubscription,
OntimeEvent,
OntimeRundown,
OscSubscription,
SupportedEvent,
TimeStrategy,
TimerType,
} from 'ontime-types';
import {
parseRundown,
sanitiseCustomFields,
sanitiseHttpSubscriptions,
sanitiseOscSubscriptions,
} from '../parserFunctions.js';
describe('sanitiseOscSubscriptions()', () => { describe('sanitiseOscSubscriptions()', () => {
it('returns an empty array if not an array', () => { it('returns an empty array if not an array', () => {
@@ -155,3 +171,113 @@ describe('sanitiseCustomFields()', () => {
expect(sanitationResult).toStrictEqual(expectedCustomFields); 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);
});
});
+7 -2
View File
@@ -7,7 +7,7 @@ import { isColourHex } from 'ontime-utils';
* @throws {Error} Throws an error value is not found in the enum. * @throws {Error} Throws an error value is not found in the enum.
*/ */
export function coerceEnum<T>(value: unknown, list: object): T { export function coerceEnum<T>(value: unknown, list: object): T {
if (typeof value !== 'string' || !(value in list)) { if (typeof value !== 'string' || !Object.values(list).includes(value)) {
throw new Error('Invalid value received'); throw new Error('Invalid value received');
} }
return value as T; return value as T;
@@ -90,7 +90,12 @@ export function coerceColour(value: unknown): string {
if (!isColourHex(lowerCaseValue)) { if (!isColourHex(lowerCaseValue)) {
throw new Error('Invalid hex colour received'); 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'); throw new Error('Invalid colour name received');
} }
return lowerCaseValue; return lowerCaseValue;
+57
View File
@@ -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`;
}
+4
View File
@@ -7,6 +7,10 @@ export function stringToOSCArgs(argsString: string | undefined): Argument[] {
} }
const matches = splitWhitespace(argsString); const matches = splitWhitespace(argsString);
if (!matches) {
return new Array<Argument>();
}
const parsedArguments: Argument[] = matches.map((argString: string) => { const parsedArguments: Argument[] = matches.map((argString: string) => {
const argAsNum = Number(argString); const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333 // NOTE: number like: 1 2.0 33333
+2 -2
View File
@@ -49,8 +49,8 @@ export const parseRundown = (data: Partial<DatabaseModel>): OntimeRundown => {
if (isOntimeEvent(event)) { if (isOntimeEvent(event)) {
if (event.linkStart) { if (event.linkStart) {
const prevEvent = getLastEvent(rundown).lastEvent; const prevId = getLastEvent(rundown).lastEvent?.id ?? null;
event.linkStart = prevEvent.id; event.linkStart = prevId;
} }
newEvent = createEvent(event, eventIndex.toString()); newEvent = createEvent(event, eventIndex.toString());
// skip if event is invalid // skip if event is invalid
+6 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "ontime", "name": "ontime",
"version": "3.1.1", "version": "3.2.0",
"description": "Time keeping for live events", "description": "Time keeping for live events",
"keywords": [ "keywords": [
"ontime", "ontime",
@@ -41,15 +41,15 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.42.1", "@playwright/test": "^1.42.1",
"@types/node": "^18.11.18", "@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.12.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-playwright": "^1.5.2", "eslint-plugin-playwright": "^1.5.2",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^15.1.0", "lint-staged": "^15.1.0",
"prettier": "^3.0.3", "prettier": "^3.3.1",
"turbo": "^1.11.2", "turbo": "^1.11.2",
"typescript": "^5.4.3" "typescript": "^5.4.3"
}, },
+3 -3
View File
@@ -14,9 +14,9 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"typescript": "^5.4.3" "typescript": "^5.4.3"
} }
} }
@@ -2,6 +2,7 @@ export enum LogLevel {
Info = 'INFO', Info = 'INFO',
Warn = 'WARN', Warn = 'WARN',
Error = 'ERROR', Error = 'ERROR',
Severe = 'SEVERE',
} }
export type Log = { export type Log = {
+6 -6
View File
@@ -16,14 +16,14 @@
"nanoid": "^5.0.4" "nanoid": "^5.0.4"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/eslint-plugin": "^v7.12.0",
"@typescript-eslint/parser": "^6.10.0", "@typescript-eslint/parser": "^7.12.0",
"eslint": "^8.53.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.0.1", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-simple-import-sort": "^8.0.0", "eslint-plugin-simple-import-sort": "^8.0.0",
"ontime-types": "workspace:*", "ontime-types": "workspace:*",
"prettier": "^3.0.3", "prettier": "^3.3.1",
"typescript": "^5.4.3", "typescript": "^5.4.3",
"vitest": "^1.6.0" "vitest": "^1.6.0"
}, },
+214 -356
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -31,6 +31,7 @@
}, },
"dist-win": {}, "dist-win": {},
"dist-mac": {}, "dist-mac": {},
"dist-mac:local": {},
"dist-linux": {}, "dist-linux": {},
"cleanup": {} "cleanup": {}
} }