mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 20:33:47 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71092afc02 | |||
| b1ba47a80f | |||
| 9611722a6d | |||
| 92cefa1331 | |||
| c0fb865959 | |||
| 068ec800f4 | |||
| 1a8b35a5ea | |||
| 40bede200c | |||
| f3e2944c55 | |||
| 09f2874784 | |||
| 8afffb9870 | |||
| 33575c551a | |||
| 3173fb57fc | |||
| 7d33019f53 | |||
| 6526b32d4c | |||
| d6aef5ea31 | |||
| 8756b396cc | |||
| 5a2f711eab | |||
| a56e2b1c1c | |||
| da829dc09f | |||
| c4d65dbe5f | |||
| 2586b0b10c | |||
| abe27d54a2 | |||
| 1343818917 | |||
| a93cd3e9b1 | |||
| 813eb9ad86 | |||
| 34e9b1ef07 | |||
| a67b89190d | |||
| ecd151a0a8 | |||
| 4cbc09864c | |||
| 20d5d8b129 | |||
| 11db39ee73 | |||
| edb79d5819 | |||
| 715fa66444 | |||
| 27f6d10677 | |||
| f2504072ce | |||
| b11041938d | |||
| e6b4f537af | |||
| 8cabb347e9 | |||
| 22d89c6fbc | |||
| facce4f096 |
@@ -44,7 +44,8 @@ jobs:
|
|||||||
- name: Docker Setup Buildx
|
- name: Docker Setup Buildx
|
||||||
uses: docker/setup-buildx-action@v2.5.0
|
uses: docker/setup-buildx-action@v2.5.0
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build and push stable release
|
||||||
|
if: github.event.release.prerelease == false
|
||||||
uses: docker/build-push-action@v4.0.0
|
uses: docker/build-push-action@v4.0.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
@@ -54,3 +55,14 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:latest
|
||||||
|
|
||||||
|
- name: Build and push pre-release
|
||||||
|
if: github.event.release.prerelease == true
|
||||||
|
uses: docker/build-push-action@v4.0.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||||
|
# Push is a shorthand for --output=type=registry
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/ontime:${{ env.RELEASE_VERSION }} , ${{ secrets.DOCKERHUB_USERNAME }}/ontime:nightly
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
|||||||
From the project root, run the following commands
|
From the project root, run the following commands
|
||||||
|
|
||||||
- __Install the project dependencies__ by running `pnpm i`
|
- __Install the project dependencies__ by running `pnpm i`
|
||||||
|
- __Build packages__ by running `pnpm build:localdocker`
|
||||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
## Download the latest releases here
|
## Download the latest releases here
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-around">
|
<div style="display: flex; justify-content: space-around">
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
|
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
|
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
|
||||||
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
|
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
|
||||||
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "2.21.3",
|
"version": "2.28.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/react": "^2.7.0",
|
"@chakra-ui/react": "^2.7.0",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"build:local": "cross-env NODE_ENV=local vite build",
|
"build:local": "cross-env NODE_ENV=local vite build",
|
||||||
"build:electron": "cross-env NODE_ENV=local vite build",
|
"build:electron": "cross-env NODE_ENV=local vite build",
|
||||||
"build:docker": "vite build",
|
"build:docker": "vite build",
|
||||||
|
"build:localdocker": "cross-env NODE_ENV=local vite build",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lint-staged": "eslint",
|
"lint-staged": "eslint",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export const USERFIELDS = ['userFields'];
|
|||||||
export const RUNDOWN = ['rundown'];
|
export const RUNDOWN = ['rundown'];
|
||||||
export const APP_INFO = ['appinfo'];
|
export const APP_INFO = ['appinfo'];
|
||||||
export const OSC_SETTINGS = ['oscSettings'];
|
export const OSC_SETTINGS = ['oscSettings'];
|
||||||
|
export const HTTP_SETTINGS = ['httpSettings'];
|
||||||
export const APP_SETTINGS = ['appSettings'];
|
export const APP_SETTINGS = ['appSettings'];
|
||||||
export const VIEW_SETTINGS = ['viewSettings'];
|
export const VIEW_SETTINGS = ['viewSettings'];
|
||||||
export const RUNTIME = ['runtimeStore'];
|
export const RUNTIME = ['runtimeStore'];
|
||||||
|
export const SHEET_STATE = ['sheetState'];
|
||||||
|
|
||||||
const location = window.location;
|
const location = window.location;
|
||||||
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
const socketProtocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Alias,
|
Alias,
|
||||||
DatabaseModel,
|
DatabaseModel,
|
||||||
GetInfo,
|
GetInfo,
|
||||||
|
HttpSettings,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
OSCSettings,
|
OSCSettings,
|
||||||
OscSubscription,
|
OscSubscription,
|
||||||
@@ -104,6 +105,23 @@ export async function getOSC(): Promise<OSCSettings> {
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to retrieve http settings
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function getHTTP(): Promise<HttpSettings> {
|
||||||
|
const res = await axios.get(`${ontimeURL}/http`);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description HTTP request to mutate http settings
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
export async function postHTTP(data: HttpSettings) {
|
||||||
|
return axios.post(`${ontimeURL}/http`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description HTTP request to mutate osc settings
|
* @description HTTP request to mutate osc settings
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
@@ -227,3 +245,76 @@ export async function getLatestVersion(): Promise<HasUpdate> {
|
|||||||
export async function postNew(initialData: Partial<ProjectData>) {
|
export async function postNew(initialData: Partial<ProjectData>) {
|
||||||
return axios.post(`${ontimeURL}/new`, initialData);
|
return axios.post(`${ontimeURL}/new`, initialData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 1
|
||||||
|
*/
|
||||||
|
export const uploadSheetClientFile = async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('userFile', file);
|
||||||
|
const res = await axios
|
||||||
|
.post(`${ontimeURL}/sheet/clientsecret`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response.data.id);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 1 test
|
||||||
|
*/
|
||||||
|
export const getClientSecrect = async () => {
|
||||||
|
const response = await axios.get(`${ontimeURL}/sheet/clientsecret`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 2
|
||||||
|
*/
|
||||||
|
export const getSheetsAuthUrl = async () => {
|
||||||
|
const response = await axios.get(`${ontimeURL}/sheet/authentication/url`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 2 test
|
||||||
|
*/
|
||||||
|
export const getAuthentication = async () => {
|
||||||
|
const response = await axios.get(`${ontimeURL}/sheet/authentication`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 3
|
||||||
|
* @returns worksheetOptions
|
||||||
|
*/
|
||||||
|
export const postId = async (id: string) => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet/id`, { id });
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 4
|
||||||
|
*/
|
||||||
|
export const postWorksheet = async (id: string, worksheet: string) => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet/worksheet`, { id, worksheet });
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 5
|
||||||
|
*/
|
||||||
|
export const postPreviewSheet = async (id: string, options: ExcelImportMap) => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet/pull`, { id, options });
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 5
|
||||||
|
*/
|
||||||
|
export const postPushSheet = async (id: string, options: ExcelImportMap) => {
|
||||||
|
const response = await axios.post(`${ontimeURL}/sheet-push`, { id, options });
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ interface TimeInputProps {
|
|||||||
time?: number;
|
time?: number;
|
||||||
delay?: number;
|
delay?: number;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
validationHandler: (entry: TimeEntryField, val: number) => boolean;
|
|
||||||
previousEnd?: number;
|
previousEnd?: number;
|
||||||
warning?: string;
|
tooltip?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonInitial(name: TimeEntryField) {
|
function ButtonInitial(name: TimeEntryField) {
|
||||||
@@ -29,25 +28,15 @@ function ButtonInitial(name: TimeEntryField) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ButtonTooltip(name: TimeEntryField, warning?: string) {
|
function ButtonTooltip(name: TimeEntryField, tooltip?: string) {
|
||||||
if (name === 'timeStart') return `Start${warning ? `: ${warning}` : ''}`;
|
if (name === 'timeStart') return `Start${tooltip ? `: ${tooltip}` : ''}`;
|
||||||
if (name === 'timeEnd') return `End${warning ? `: ${warning}` : ''}`;
|
if (name === 'timeEnd') return `End${tooltip ? `: ${tooltip}` : ''}`;
|
||||||
if (name === 'durationOverride') return `Duration${warning ? `: ${warning}` : ''}`;
|
if (name === 'durationOverride') return `Duration${tooltip ? `: ${tooltip}` : ''}`;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TimeInput(props: TimeInputProps) {
|
export default function TimeInput(props: TimeInputProps) {
|
||||||
const {
|
const { id, name, submitHandler, time = 0, delay = 0, placeholder, previousEnd = 0 } = props;
|
||||||
id,
|
|
||||||
name,
|
|
||||||
submitHandler,
|
|
||||||
time = 0,
|
|
||||||
delay = 0,
|
|
||||||
placeholder,
|
|
||||||
validationHandler,
|
|
||||||
previousEnd = 0,
|
|
||||||
warning,
|
|
||||||
} = props;
|
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [value, setValue] = useState<string>('');
|
const [value, setValue] = useState<string>('');
|
||||||
@@ -103,15 +92,12 @@ export default function TimeInput(props: TimeInputProps) {
|
|||||||
// check if time is different from before
|
// check if time is different from before
|
||||||
if (newValMillis === time) return false;
|
if (newValMillis === time) return false;
|
||||||
|
|
||||||
// validate with parent
|
|
||||||
if (!validationHandler(name, newValMillis)) return false;
|
|
||||||
|
|
||||||
// update entry
|
// update entry
|
||||||
submitHandler(name, newValMillis);
|
submitHandler(name, newValMillis);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[name, previousEnd, submitHandler, time, validationHandler],
|
[name, previousEnd, submitHandler, time],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,11 +157,11 @@ export default function TimeInput(props: TimeInputProps) {
|
|||||||
|
|
||||||
const isDelayed = delay !== 0;
|
const isDelayed = delay !== 0;
|
||||||
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
const inputClasses = cx([style.timeInput, isDelayed ? style.delayed : null]);
|
||||||
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null, warning ? style.warn : null]);
|
const buttonClasses = cx([style.inputButton, isDelayed ? style.delayed : null]);
|
||||||
|
|
||||||
const TooltipLabel = useMemo(() => {
|
const TooltipLabel = useMemo(() => {
|
||||||
return ButtonTooltip(name, warning);
|
return ButtonTooltip(name, '');
|
||||||
}, [name, warning]);
|
}, [name]);
|
||||||
|
|
||||||
const ButtonText = useMemo(() => {
|
const ButtonText = useMemo(() => {
|
||||||
return ButtonInitial(name);
|
return ButtonInitial(name);
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ $icon-color: $ui-white;
|
|||||||
$button-bg: $gray-1050;
|
$button-bg: $gray-1050;
|
||||||
$button-size: 48px;
|
$button-size: 48px;
|
||||||
|
|
||||||
.mirror {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttonContainer {
|
.buttonContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function NavigationMenu() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
const { isFullScreen, toggleFullScreen } = useFullscreen();
|
||||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
const { toggleMirror } = useViewOptionsStore();
|
||||||
const [showButton, setShowButton] = useState(false);
|
const [showButton, setShowButton] = useState(false);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
@@ -63,7 +63,7 @@ function NavigationMenu() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id='navigation-menu-portal' ref={menuRef} className={mirror ? style.mirror : ''}>
|
<div id='navigation-menu-portal' ref={menuRef}>
|
||||||
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
<RenameClientModal isOpen={isOpen} onClose={onClose} />
|
||||||
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
<div className={`${style.buttonContainer} ${!showButton && !showMenu ? style.hidden : ''}`}>
|
||||||
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
<button onClick={toggleMenu} aria-label='toggle menu' className={style.navButton}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Input, Select, Switch } from '@chakra-ui/react';
|
import { Input, InputGroup, InputLeftElement, Select, Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../utils/viewUtils';
|
import { isStringBoolean } from '../../utils/viewUtils';
|
||||||
|
|
||||||
@@ -9,16 +9,22 @@ interface EditFormInputProps {
|
|||||||
paramField: ParamField;
|
paramField: ParamField;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ParamInput({ paramField }: EditFormInputProps) {
|
export default function ParamInput(props: EditFormInputProps) {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { id, type } = paramField;
|
const { paramField } = props;
|
||||||
|
const { id, type, defaultValue } = paramField;
|
||||||
|
|
||||||
if (type === 'option') {
|
if (type === 'option') {
|
||||||
const optionFromParams = searchParams.get(id);
|
const optionFromParams = searchParams.get(id);
|
||||||
const defaultOptionValue = optionFromParams || undefined;
|
const defaultOptionValue = optionFromParams || defaultValue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select placeholder='Select an option' variant='ontime' name={id} defaultValue={defaultOptionValue}>
|
<Select
|
||||||
|
placeholder={defaultValue ? undefined : 'Select an option'}
|
||||||
|
variant='ontime'
|
||||||
|
name={id}
|
||||||
|
defaultValue={defaultOptionValue}
|
||||||
|
>
|
||||||
{Object.entries(paramField.values).map(([key, value]) => (
|
{Object.entries(paramField.values).map(([key, value]) => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
{value}
|
{value}
|
||||||
@@ -29,19 +35,38 @@ export default function ParamInput({ paramField }: EditFormInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'boolean') {
|
if (type === 'boolean') {
|
||||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) ?? false;
|
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||||
|
|
||||||
// checked value should be 'true', so it can be captured by the form event
|
// checked value should be 'true', so it can be captured by the form event
|
||||||
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
|
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
const defaultNumberValue = searchParams.get(id) ?? '';
|
const { prefix, placeholder } = paramField;
|
||||||
|
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
|
||||||
|
|
||||||
return <Input type='number' step='any' variant='ontime-filled' name={id} defaultValue={defaultNumberValue} />;
|
return (
|
||||||
|
<InputGroup variant='ontime-filled'>
|
||||||
|
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
step='any'
|
||||||
|
variant='ontime-filled'
|
||||||
|
name={id}
|
||||||
|
defaultValue={defaultNumberValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultStringValue = searchParams.get(id) ?? '';
|
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||||
|
const { prefix, placeholder } = paramField;
|
||||||
|
|
||||||
return <Input variant='ontime-filled' name={id} defaultValue={defaultStringValue} />;
|
return (
|
||||||
|
<InputGroup variant='ontime-filled'>
|
||||||
|
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
||||||
|
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ import style from './ViewParamsEditor.module.scss';
|
|||||||
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
type ViewParamsObj = { [key: string]: string | FormDataEntryValue };
|
||||||
type SavedViewParams = Record<string, ViewParamsObj>;
|
type SavedViewParams = Record<string, ViewParamsObj>;
|
||||||
|
|
||||||
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
|
const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ParamField[]) => {
|
||||||
Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
const defaultValues = paramFields.map(({ defaultValue }) => String(defaultValue));
|
||||||
|
|
||||||
|
return Object.entries(paramsObj).reduce((newSearchParams, [id, value]) => {
|
||||||
if (typeof value === 'string' && value.length) {
|
if (typeof value === 'string' && value.length) {
|
||||||
|
if (defaultValues.includes(value)) {
|
||||||
|
return newSearchParams;
|
||||||
|
}
|
||||||
|
|
||||||
newSearchParams.set(id, value);
|
newSearchParams.set(id, value);
|
||||||
|
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
@@ -32,6 +38,7 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj) =>
|
|||||||
|
|
||||||
return newSearchParams;
|
return newSearchParams;
|
||||||
}, new URLSearchParams());
|
}, new URLSearchParams());
|
||||||
|
};
|
||||||
|
|
||||||
interface EditFormDrawerProps {
|
interface EditFormDrawerProps {
|
||||||
paramFields: ParamField[];
|
paramFields: ParamField[];
|
||||||
@@ -72,31 +79,30 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const onEditDrawerClose = () => {
|
const onCloseWithoutSaving = () => {
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
searchParams.delete('edit');
|
searchParams.delete('edit');
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearParams = () => {
|
const resetParams = () => {
|
||||||
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
setStoredViewParams({ ...storedViewParams, [pathname]: {} });
|
||||||
setSearchParams();
|
setSearchParams();
|
||||||
onClose();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||||
formEvent.preventDefault();
|
formEvent.preventDefault();
|
||||||
|
|
||||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject);
|
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, paramFields);
|
||||||
|
|
||||||
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
setStoredViewParams({ ...storedViewParams, [pathname]: newParamsObject });
|
||||||
setSearchParams(newSearchParams);
|
setSearchParams(newSearchParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer isOpen={isOpen} placement='right' onClose={onEditDrawerClose} size='lg'>
|
<Drawer isOpen={isOpen} placement='right' onClose={onCloseWithoutSaving} size='lg'>
|
||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerHeader className={style.drawerHeader}>
|
<DrawerHeader className={style.drawerHeader}>
|
||||||
@@ -119,10 +125,10 @@ export default function ViewParamsEditor({ paramFields }: EditFormDrawerProps) {
|
|||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
|
|
||||||
<DrawerFooter className={style.drawerFooter}>
|
<DrawerFooter className={style.drawerFooter}>
|
||||||
<Button variant='ontime-ghosted' onClick={clearParams} type='reset'>
|
<Button variant='ontime-ghosted' onClick={resetParams} type='reset'>
|
||||||
Clear
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='ontime-subtle' onClick={onEditDrawerClose}>
|
<Button variant='ontime-subtle' onClick={onCloseWithoutSaving}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
<Button variant='ontime-filled' form='edit-params-form' type='submit'>
|
||||||
|
|||||||
@@ -1,46 +1,56 @@
|
|||||||
import { UserFields } from 'ontime-types';
|
import { UserFields } from 'ontime-types';
|
||||||
|
import { TimeFormat } from 'ontime-types/src/definitions/core/TimeFormat.type';
|
||||||
|
|
||||||
import { ParamField } from './types';
|
import { ParamField } from './types';
|
||||||
|
|
||||||
export const TIME_FORMAT_OPTION: ParamField = {
|
export const getTimeOption = (timeFormat: TimeFormat): ParamField => ({
|
||||||
id: 'format',
|
id: 'format',
|
||||||
title: '12 / 24 hour timer',
|
title: '12 / 24 hour timer',
|
||||||
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
description: 'Whether to show the time in 12 or 24 hour mode. Overrides the global setting from preferences',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
values: { '12': '12 hour AM/PM', '24': '24 hour' },
|
||||||
};
|
defaultValue: timeFormat,
|
||||||
|
});
|
||||||
|
|
||||||
export const CLOCK_OPTIONS: ParamField[] = [
|
export const getClockOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Background colour in hexadecimal',
|
description: 'Background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
title: 'Text Colour',
|
title: 'Text Colour',
|
||||||
description: 'Text colour in hexadecimal',
|
description: 'Text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: 'fffff (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'textbg',
|
id: 'textbg',
|
||||||
title: 'Text Background',
|
title: 'Text Background',
|
||||||
description: 'Colour of text background in hexadecimal',
|
description: 'Colour of text background in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font',
|
id: 'font',
|
||||||
title: 'Font',
|
title: 'Font',
|
||||||
description: 'Font family, will use the fonts available in the system',
|
description: 'Font family, will use the fonts available in the system',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: 'Arial Black (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'size',
|
id: 'size',
|
||||||
title: 'Text Size',
|
title: 'Text Size',
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '1 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'alignx',
|
id: 'alignx',
|
||||||
@@ -48,12 +58,14 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
|||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsetx',
|
id: 'offsetx',
|
||||||
title: 'Offset Horizontal',
|
title: 'Offset Horizontal',
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aligny',
|
id: 'aligny',
|
||||||
@@ -61,46 +73,53 @@ export const CLOCK_OPTIONS: ParamField[] = [
|
|||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsety',
|
id: 'offsety',
|
||||||
title: 'Offset Vertical',
|
title: 'Offset Vertical',
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const TIMER_OPTIONS: ParamField[] = [
|
export const getTimerOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'hideClock',
|
id: 'hideClock',
|
||||||
title: 'Hide Time Now',
|
title: 'Hide Time Now',
|
||||||
description: 'Hides the Time Now field',
|
description: 'Hides the Time Now field',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideCards',
|
id: 'hideCards',
|
||||||
title: 'Hide Cards',
|
title: 'Hide Cards',
|
||||||
description: 'Hides the Now and Next cards',
|
description: 'Hides the Now and Next cards',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideProgress',
|
id: 'hideProgress',
|
||||||
title: 'Hide progress bar',
|
title: 'Hide progress bar',
|
||||||
description: 'Hides the progress bar',
|
description: 'Hides the progress bar',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideMessage',
|
id: 'hideMessage',
|
||||||
title: 'Hide Presenter Message',
|
title: 'Hide Presenter Message',
|
||||||
description: 'Prevents the screen from displaying messages from the presenter',
|
description: 'Prevents the screen from displaying messages from the presenter',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideExternal',
|
id: 'hideExternal',
|
||||||
title: 'Hide External',
|
title: 'Hide External',
|
||||||
description: 'Prevents the screen from displaying the external field',
|
description: 'Prevents the screen from displaying the external field',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -109,31 +128,39 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Background colour in hexadecimal',
|
description: 'Background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
title: 'Text Colour',
|
title: 'Text Colour',
|
||||||
description: 'Text colour in hexadecimal',
|
description: 'Text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: 'fffff (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'textbg',
|
id: 'textbg',
|
||||||
title: 'Text Background',
|
title: 'Text Background',
|
||||||
description: 'Colour of text background in hexadecimal',
|
description: 'Colour of text background in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000000 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'font',
|
id: 'font',
|
||||||
title: 'Font',
|
title: 'Font',
|
||||||
description: 'Font family, will use the fonts available in the system',
|
description: 'Font family, will use the fonts available in the system',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: 'Arial Black (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'size',
|
id: 'size',
|
||||||
title: 'Text Size',
|
title: 'Text Size',
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '1 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'alignx',
|
id: 'alignx',
|
||||||
@@ -141,12 +168,14 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
description: 'Moves the horizontally in page to start = left | center | end = right',
|
description: 'Moves the horizontally in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsetx',
|
id: 'offsetx',
|
||||||
title: 'Offset Horizontal',
|
title: 'Offset Horizontal',
|
||||||
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
description: 'Offsets the timer horizontal position by a given amount in pixels',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'aligny',
|
id: 'aligny',
|
||||||
@@ -154,145 +183,162 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
|
|||||||
description: 'Moves the vertically in page to start = left | center | end = right',
|
description: 'Moves the vertically in page to start = left | center | end = right',
|
||||||
type: 'option',
|
type: 'option',
|
||||||
values: { start: 'Start', center: 'Center', end: 'End' },
|
values: { start: 'Start', center: 'Center', end: 'End' },
|
||||||
|
defaultValue: 'center',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'offsety',
|
id: 'offsety',
|
||||||
title: 'Offset Vertical',
|
title: 'Offset Vertical',
|
||||||
description: 'Offsets the timer vertical position by a given amount in pixels',
|
description: 'Offsets the timer vertical position by a given amount in pixels',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '0 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideovertime',
|
id: 'hideovertime',
|
||||||
title: 'Hide Overtime',
|
title: 'Hide Overtime',
|
||||||
description: 'Whether to suppress overtime styles (red borders and red text)',
|
description: 'Whether to suppress overtime styles (red borders and red text)',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hidemessages',
|
id: 'hidemessages',
|
||||||
title: 'Hide Message Overlay',
|
title: 'Hide Message Overlay',
|
||||||
description: 'Whether to hide the overlay from showing timer screen messages',
|
description: 'Whether to hide the overlay from showing timer screen messages',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hideendmessage',
|
id: 'hideendmessage',
|
||||||
title: 'Hide End Message',
|
title: 'Hide End Message',
|
||||||
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
export const LOWER_THIRDS_OPTIONS: ParamField[] = [
|
||||||
{
|
|
||||||
id: 'preset',
|
|
||||||
title: 'Preset',
|
|
||||||
description: 'Selects a style preset (0-1)',
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'size',
|
id: 'size',
|
||||||
title: 'Size',
|
title: 'Size',
|
||||||
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '1 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'transition',
|
id: 'transition',
|
||||||
title: 'Transition',
|
title: 'Transition',
|
||||||
description: 'Transition in time in seconds (default 5)',
|
description: 'Transition in time in seconds (default 3)',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'text',
|
id: 'text',
|
||||||
title: 'Text Colour',
|
title: 'Text Colour',
|
||||||
description: 'Text colour in hexadecimal',
|
description: 'Text colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: 'fffffa (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bg',
|
id: 'bg',
|
||||||
title: 'Text Background',
|
title: 'Text Background',
|
||||||
description: 'Text background colour in hexadecimal',
|
description: 'Text background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000033 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'key',
|
id: 'key',
|
||||||
title: 'Key Colour',
|
title: 'Key Colour',
|
||||||
description: 'Screen background colour in hexadecimal',
|
description: 'Screen background colour in hexadecimal',
|
||||||
|
prefix: '#',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
placeholder: '00000033 (default)',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fadeout',
|
id: 'fadeout',
|
||||||
title: 'Fadeout',
|
title: 'Fadeout',
|
||||||
description: 'Time (in seconds) the lower third displays before fading out',
|
description: 'Time (in seconds) the lower third displays before fading out',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '3 (default)',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BACKSTAGE_OPTIONS: ParamField[] = [
|
export const getBackstageOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'hidePast',
|
id: 'hidePast',
|
||||||
title: 'Hide past events',
|
title: 'Hide past events',
|
||||||
description: 'Scheduler will only show upcoming events',
|
description: 'Scheduler will only show upcoming events',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stopCycle',
|
id: 'stopCycle',
|
||||||
title: 'Stop cycling through event pages',
|
title: 'Stop cycling through event pages',
|
||||||
description: 'Schedule will not auto-cycle through events',
|
description: 'Schedule will not auto-cycle through events',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'eventsPerPage',
|
id: 'eventsPerPage',
|
||||||
title: 'Events per page',
|
title: 'Events per page',
|
||||||
description: 'Sets the number of events on the page, can cause overlow',
|
description: 'Sets the number of events on the page, can cause overlow',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '7 (default)',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PUBLIC_OPTIONS: ParamField[] = [
|
export const getPublicOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'hidePast',
|
id: 'hidePast',
|
||||||
title: 'Hide past events',
|
title: 'Hide past events',
|
||||||
description: 'Scheduler will only show upcoming events',
|
description: 'Scheduler will only show upcoming events',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stopCycle',
|
id: 'stopCycle',
|
||||||
title: 'Stop cycling through event pages',
|
title: 'Stop cycling through event pages',
|
||||||
description: 'Schedule will not auto-cycle through events',
|
description: 'Schedule will not auto-cycle through events',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'eventsPerPage',
|
id: 'eventsPerPage',
|
||||||
title: 'Events per page',
|
title: 'Events per page',
|
||||||
description: 'Sets the number of events on the page, can cause overlow',
|
description: 'Sets the number of events on the page, can cause overlow',
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
placeholder: '7 (default)',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
export const STUDIO_CLOCK_OPTIONS: ParamField[] = [
|
export const getStudioClockOptions = (timeFormat: TimeFormat): ParamField[] => [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'seconds',
|
id: 'seconds',
|
||||||
title: 'Show Seconds',
|
title: 'Show Seconds',
|
||||||
description: 'Shows seconds in clock',
|
description: 'Shows seconds in clock',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
export const getOperatorOptions = (userFields: UserFields, timeFormat: TimeFormat): ParamField[] => {
|
||||||
return [
|
return [
|
||||||
TIME_FORMAT_OPTION,
|
getTimeOption(timeFormat),
|
||||||
{
|
{
|
||||||
id: 'showseconds',
|
id: 'showseconds',
|
||||||
title: 'Show seconds',
|
title: 'Show seconds',
|
||||||
description: 'Schedule shows hh:mm:ss',
|
description: 'Schedule shows hh:mm:ss',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hidepast',
|
id: 'hidepast',
|
||||||
title: 'Hide Past Events',
|
title: 'Hide Past Events',
|
||||||
description: 'Whether to events that have passed',
|
description: 'Whether to events that have passed',
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'main',
|
id: 'main',
|
||||||
@@ -334,5 +380,12 @@ export const getOperatorOptions = (userFields: UserFields): ParamField[] => {
|
|||||||
user9: userFields.user9 || 'user9',
|
user9: userFields.user9 || 'user9',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'shouldEdit',
|
||||||
|
title: 'Edit user field',
|
||||||
|
description: 'Allows editing an events user field by long pressing on it. Needs a selected highlighted field',
|
||||||
|
type: 'boolean',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,13 @@ type BaseField = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OptionsField = { type: 'option'; values: Record<string, string> };
|
type OptionsField = {
|
||||||
type StringField = { type: 'string' };
|
type: 'option';
|
||||||
type BooleanField = { type: 'boolean' };
|
values: Record<string, string>;
|
||||||
type NumberField = { type: 'number' };
|
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);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { HttpSettings } from 'ontime-types';
|
||||||
|
|
||||||
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
|
import { HTTP_SETTINGS } from '../api/apiConstants';
|
||||||
|
import { logAxiosError } from '../api/apiUtils';
|
||||||
|
import { getHTTP, postHTTP } from '../api/ontimeApi';
|
||||||
|
import { httpPlaceholder } from '../models/Http';
|
||||||
|
import { ontimeQueryClient } from '../queryClient';
|
||||||
|
|
||||||
|
export function useHttpSettings() {
|
||||||
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
|
queryKey: HTTP_SETTINGS,
|
||||||
|
queryFn: getHTTP,
|
||||||
|
placeholderData: httpPlaceholder,
|
||||||
|
retry: 5,
|
||||||
|
retryDelay: (attempt: number) => attempt * 2500,
|
||||||
|
refetchInterval: queryRefetchIntervalSlow,
|
||||||
|
networkMode: 'always',
|
||||||
|
});
|
||||||
|
|
||||||
|
// we need to jump through some hoops because of the type op port
|
||||||
|
return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePostHttpSettings() {
|
||||||
|
const { isPending, mutateAsync } = useMutation({
|
||||||
|
mutationFn: postHTTP,
|
||||||
|
onError: (error) => logAxiosError('Error saving HTTP settings', error),
|
||||||
|
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }),
|
||||||
|
});
|
||||||
|
return { isPending, mutateAsync };
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-nocheck -- working on it
|
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { OSCSettings } from 'ontime-types';
|
|
||||||
|
|
||||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||||
import { OSC_SETTINGS } from '../api/apiConstants';
|
import { OSC_SETTINGS } from '../api/apiConstants';
|
||||||
@@ -13,7 +10,10 @@ import { ontimeQueryClient } from '../queryClient';
|
|||||||
export default function useOscSettings() {
|
export default function useOscSettings() {
|
||||||
const { data, status, isFetching, isError, refetch } = useQuery({
|
const { data, status, isFetching, isError, refetch } = useQuery({
|
||||||
queryKey: OSC_SETTINGS,
|
queryKey: OSC_SETTINGS,
|
||||||
queryFn: getOSC,
|
queryFn: async () => {
|
||||||
|
const oscData = await getOSC();
|
||||||
|
return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) };
|
||||||
|
},
|
||||||
placeholderData: oscPlaceholderSettings,
|
placeholderData: oscPlaceholderSettings,
|
||||||
retry: 5,
|
retry: 5,
|
||||||
retryDelay: (attempt: number) => attempt * 2500,
|
retryDelay: (attempt: number) => attempt * 2500,
|
||||||
@@ -21,8 +21,7 @@ export default function useOscSettings() {
|
|||||||
networkMode: 'always',
|
networkMode: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// we need to jump through some hoops because of the type op port
|
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
|
||||||
return { data: data! as unknown as OSCSettings, status, isFetching, isError, refetch };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOscSettingsMutation() {
|
export function useOscSettingsMutation() {
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ export const setPlayback = {
|
|||||||
reload: () => {
|
reload: () => {
|
||||||
socketSendJson('reload');
|
socketSendJson('reload');
|
||||||
},
|
},
|
||||||
delay: (amount: number) => {
|
addTime: (amount: number) => {
|
||||||
socketSendJson('delay', amount);
|
socketSendJson('addtime', amount);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
export const httpPlaceholder = {
|
import { HttpSettings } from 'ontime-types';
|
||||||
onLoad: {
|
|
||||||
url: '',
|
export const httpPlaceholder: HttpSettings = {
|
||||||
enabled: false,
|
enabledOut: false,
|
||||||
},
|
subscriptions: {
|
||||||
onStart: {
|
onLoad: [],
|
||||||
url: '',
|
onStart: [],
|
||||||
enabled: false,
|
onUpdate: [],
|
||||||
},
|
onPause: [],
|
||||||
onUpdate: {
|
onStop: [],
|
||||||
url: '',
|
onFinish: [],
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onPause: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onStop: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
onFinish: {
|
|
||||||
url: '',
|
|
||||||
enabled: false,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isIPAddress, isOnlyNumbers } from '../regex';
|
import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex';
|
||||||
|
|
||||||
describe('simple tests for regex', () => {
|
describe('simple tests for regex', () => {
|
||||||
test('isOnlyNumbers', () => {
|
test('isOnlyNumbers', () => {
|
||||||
@@ -24,4 +24,16 @@ describe('simple tests for regex', () => {
|
|||||||
expect(isIPAddress.test(t)).toBe(false);
|
expect(isIPAddress.test(t)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startsWithHttp', () => {
|
||||||
|
const right = ['http://test'];
|
||||||
|
const wrong = ['https://test', 'testing', '123.0.1'];
|
||||||
|
|
||||||
|
right.forEach((t) => {
|
||||||
|
expect(startsWithHttp.test(t)).toBe(true);
|
||||||
|
});
|
||||||
|
wrong.forEach((t) => {
|
||||||
|
expect(startsWithHttp.test(t)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { cx } from '../styleUtils';
|
import { cx, getAccessibleColour } from '../styleUtils';
|
||||||
|
|
||||||
import style from './styleUtils.module.scss';
|
import style from './styleUtils.module.scss';
|
||||||
|
|
||||||
@@ -13,3 +13,24 @@ describe('cx()', () => {
|
|||||||
expect(merged).toMatchSnapshot();
|
expect(merged).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAccessibleColour()', () => {
|
||||||
|
it('handles named colours', () => {
|
||||||
|
const colour = 'red';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(colour);
|
||||||
|
expect(backgroundColor).toBe('#FF0000FF');
|
||||||
|
expect(color).toBe('#fffffa');
|
||||||
|
});
|
||||||
|
it('handles hex colours', () => {
|
||||||
|
const colour = '#0F0';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(colour);
|
||||||
|
expect(backgroundColor).toBe('#00FF00FF');
|
||||||
|
expect(color).toBe('black');
|
||||||
|
});
|
||||||
|
it('handles transparens', () => {
|
||||||
|
const colour = '#0F08';
|
||||||
|
const { backgroundColor, color } = getAccessibleColour(colour);
|
||||||
|
expect(backgroundColor).toBe('#0C940CFF');
|
||||||
|
expect(color).toBe('#fffffa');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export const isOnlyNumbers = /^\d+$/;
|
export const isOnlyNumbers = /^\d+$/;
|
||||||
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/;
|
||||||
|
export const startsWithHttp = /^http:\/\//;
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ type ColourCombination = {
|
|||||||
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
export const getAccessibleColour = (bgColour?: string): ColourCombination => {
|
||||||
if (bgColour) {
|
if (bgColour) {
|
||||||
try {
|
try {
|
||||||
const textColor = Color(bgColour).isLight() ? 'black' : '#fffffa';
|
const originalColour = Color(bgColour);
|
||||||
return { backgroundColor: bgColour, color: textColor };
|
const backgroundColorMix = originalColour.alpha(1).mix(Color('#1a1a1a'), 1 - originalColour.alpha());
|
||||||
|
const textColor = backgroundColorMix.isLight() ? 'black' : '#fffffa';
|
||||||
|
return { backgroundColor: backgroundColorMix.hexa(), color: textColor };
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
/* we do not handle errors here */
|
/* we do not handle errors here */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { backgroundColor: '#000', color: '#fffffa' };
|
return { backgroundColor: '#1a1a1a', color: '#fffffa' };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,44 +1 @@
|
|||||||
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
|
export type TimeEntryField = 'timeStart' | 'timeEnd' | 'durationOverride';
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Checks which field the value relates to
|
|
||||||
*/
|
|
||||||
export const handleTimeEntry = (
|
|
||||||
field: TimeEntryField,
|
|
||||||
val: number,
|
|
||||||
timeStart: number,
|
|
||||||
timeEnd: number,
|
|
||||||
): { start: number; end: number; durationOverride: boolean } => {
|
|
||||||
let start = timeStart;
|
|
||||||
let end = timeEnd;
|
|
||||||
let durationOverride = false;
|
|
||||||
|
|
||||||
if (field === 'timeStart') {
|
|
||||||
start = val;
|
|
||||||
} else if (field === 'timeEnd') {
|
|
||||||
end = val;
|
|
||||||
} else {
|
|
||||||
durationOverride = field === 'durationOverride';
|
|
||||||
}
|
|
||||||
return { start, end, durationOverride };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Validates time entry
|
|
||||||
*/
|
|
||||||
export const validateEntry = (
|
|
||||||
field: TimeEntryField,
|
|
||||||
value: number,
|
|
||||||
timeStart: number,
|
|
||||||
timeEnd: number,
|
|
||||||
): { value: boolean; warnings: { start?: string; end?: string; duration?: string } } => {
|
|
||||||
const validate = { value: true, warnings: { start: '', end: '', duration: '' } };
|
|
||||||
|
|
||||||
const { start, end } = handleTimeEntry(field, value, timeStart, timeEnd);
|
|
||||||
|
|
||||||
if (end < start) {
|
|
||||||
validate.warnings.start = 'Start time later than end time';
|
|
||||||
}
|
|
||||||
|
|
||||||
return validate;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -86,22 +86,22 @@ export default function PlaybackTimer(props: PlaybackTimerProps) {
|
|||||||
)}
|
)}
|
||||||
<div className={style.btn}>
|
<div className={style.btn}>
|
||||||
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
<Tooltip label='Remove 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||||
<TapButton onClick={() => setPlayback.delay(-1)} disabled={disableButtons} aspect='square'>
|
<TapButton onClick={() => setPlayback.addTime(-60)} disabled={disableButtons} aspect='square'>
|
||||||
-1
|
-1
|
||||||
</TapButton>
|
</TapButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
<Tooltip label='Add 1 minute' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||||
<TapButton onClick={() => setPlayback.delay(1)} disabled={disableButtons} aspect='square'>
|
<TapButton onClick={() => setPlayback.addTime(60)} disabled={disableButtons} aspect='square'>
|
||||||
+1
|
+1
|
||||||
</TapButton>
|
</TapButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
<Tooltip label='Remove 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||||
<TapButton onClick={() => setPlayback.delay(-5)} disabled={disableButtons} aspect='square'>
|
<TapButton onClick={() => setPlayback.addTime(-5 * 60)} disabled={disableButtons} aspect='square'>
|
||||||
-5
|
-5
|
||||||
</TapButton>
|
</TapButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
<Tooltip label='Add 5 minutes' openDelay={tooltipDelayMid} shouldWrapChildren={disableButtons}>
|
||||||
<TapButton onClick={() => setPlayback.delay(+5)} disabled={disableButtons} aspect='square'>
|
<TapButton onClick={() => setPlayback.addTime(+5 * 60)} disabled={disableButtons} aspect='square'>
|
||||||
+5
|
+5
|
||||||
</TapButton>
|
</TapButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundo
|
|||||||
|
|
||||||
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
import useFollowComponent from '../../common/hooks/useFollowComponent';
|
||||||
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
|
||||||
|
import { getAccessibleColour } from '../../common/utils/styleUtils';
|
||||||
|
|
||||||
import BlockRow from './cuesheet-table-elements/BlockRow';
|
import BlockRow from './cuesheet-table-elements/BlockRow';
|
||||||
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
import CuesheetHeader from './cuesheet-table-elements/CuesheetHeader';
|
||||||
@@ -120,8 +121,8 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
|
|||||||
} else if (row.original.colour) {
|
} else if (row.original.colour) {
|
||||||
try {
|
try {
|
||||||
// the colour is user defined and might be invalid
|
// the colour is user defined and might be invalid
|
||||||
const colour = new Color(row.original.colour).alpha(0.25);
|
const accessibleBackgroundColor = Color(getAccessibleColour(row.original.colour).backgroundColor);
|
||||||
rowBgColour = colour.hsl().string();
|
rowBgColour = accessibleBackgroundColor.fade(0.75).hexa();
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
/* we do not handle errors here */
|
/* we do not handle errors here */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
const ownRef = useRef<HTMLTableRowElement>(null);
|
const ownRef = useRef<HTMLTableRowElement>(null);
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const bgColour = colour;
|
const textColour = getAccessibleColour(colour);
|
||||||
const textColour = getAccessibleColour(bgColour);
|
const bgColour = textColour.backgroundColor;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
@@ -55,7 +55,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
|
|||||||
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
style={{ opacity: `${isPast ? pastOpacity : '1'}` }}
|
||||||
ref={selectedRef ?? ownRef}
|
ref={selectedRef ?? ownRef}
|
||||||
>
|
>
|
||||||
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour?.color }}>
|
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
|
||||||
{eventIndex}
|
{eventIndex}
|
||||||
</td>
|
</td>
|
||||||
{isVisible ? children : null}
|
{isVisible ? children : null}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'
|
|||||||
import MenuBar from '../menu/MenuBar';
|
import MenuBar from '../menu/MenuBar';
|
||||||
import AboutModal from '../modals/about-modal/AboutModal';
|
import AboutModal from '../modals/about-modal/AboutModal';
|
||||||
import QuickStart from '../modals/quick-start/QuickStart';
|
import QuickStart from '../modals/quick-start/QuickStart';
|
||||||
|
import SheetsModal from '../modals/sheets-modal/SheetsModal';
|
||||||
import UploadModal from '../modals/upload-modal/UploadModal';
|
import UploadModal from '../modals/upload-modal/UploadModal';
|
||||||
|
|
||||||
import styles from './Editor.module.scss';
|
import styles from './Editor.module.scss';
|
||||||
@@ -28,6 +29,7 @@ export default function Editor() {
|
|||||||
} = useDisclosure();
|
} = useDisclosure();
|
||||||
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
const { isOpen: isAboutModalOpen, onOpen: onAboutModalOpen, onClose: onAboutModalClose } = useDisclosure();
|
||||||
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
const { isOpen: isQuickStartOpen, onOpen: onQuickStartOpen, onClose: onQuickStartClose } = useDisclosure();
|
||||||
|
const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure();
|
||||||
|
|
||||||
// Set window title
|
// Set window title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,6 +44,7 @@ export default function Editor() {
|
|||||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||||
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
<AboutModal onClose={onAboutModalClose} isOpen={isAboutModalOpen} />
|
||||||
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
<SettingsModal isOpen={isSettingsOpen} onClose={onSettingsClose} />
|
||||||
|
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<div className={styles.mainContainer} data-testid='event-editor'>
|
<div className={styles.mainContainer} data-testid='event-editor'>
|
||||||
<div id='settings' className={styles.settings}>
|
<div id='settings' className={styles.settings}>
|
||||||
@@ -58,6 +61,8 @@ export default function Editor() {
|
|||||||
onAboutOpen={onAboutModalOpen}
|
onAboutOpen={onAboutModalOpen}
|
||||||
isQuickStartOpen={isQuickStartOpen}
|
isQuickStartOpen={isQuickStartOpen}
|
||||||
onQuickStartOpen={onQuickStartOpen}
|
onQuickStartOpen={onQuickStartOpen}
|
||||||
|
isSheetsOpen={isSheetsOpen}
|
||||||
|
onSheetsOpen={onSheetsOpen}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo } from 'react';
|
||||||
import { Select, Switch } from '@chakra-ui/react';
|
import { Select, Switch } from '@chakra-ui/react';
|
||||||
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
|
||||||
import { calculateDuration, millisToString } from 'ontime-utils';
|
import { calculateDuration, millisToString } from 'ontime-utils';
|
||||||
@@ -7,7 +7,6 @@ import TimeInput from '../../../common/components/input/time-input/TimeInput';
|
|||||||
import { useEventAction } from '../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../common/hooks/useEventAction';
|
||||||
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||||
import { cx } from '../../../common/utils/styleUtils';
|
import { cx } from '../../../common/utils/styleUtils';
|
||||||
import { TimeEntryField, validateEntry } from '../../../common/utils/timesManager';
|
|
||||||
|
|
||||||
import style from '../EventEditor.module.scss';
|
import style from '../EventEditor.module.scss';
|
||||||
|
|
||||||
@@ -29,13 +28,6 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
|
const { eventId, timeStart, timeEnd, duration, delay, isPublic, endAction, timerType } = props;
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
|
|
||||||
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
|
|
||||||
|
|
||||||
const timerValidationHandler = (entry: TimeEntryField, val: number) => {
|
|
||||||
const valid = validateEntry(entry, val, timeStart, timeEnd);
|
|
||||||
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
|
|
||||||
return valid.value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
|
const handleSubmit = (field: TimeActions, value: number | string | boolean) => {
|
||||||
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
||||||
@@ -87,11 +79,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
id='timeStart'
|
id='timeStart'
|
||||||
name='timeStart'
|
name='timeStart'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={timerValidationHandler}
|
|
||||||
time={timeStart}
|
time={timeStart}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
placeholder='Start'
|
placeholder='Start'
|
||||||
warning={warning.start}
|
|
||||||
/>
|
/>
|
||||||
<label className={inputTimeLabels} htmlFor='timeEnd'>
|
<label className={inputTimeLabels} htmlFor='timeEnd'>
|
||||||
{endLabel}
|
{endLabel}
|
||||||
@@ -100,11 +90,9 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
id='timeEnd'
|
id='timeEnd'
|
||||||
name='timeEnd'
|
name='timeEnd'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={timerValidationHandler}
|
|
||||||
time={timeEnd}
|
time={timeEnd}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
placeholder='End'
|
placeholder='End'
|
||||||
warning={warning.end}
|
|
||||||
/>
|
/>
|
||||||
<label className={style.inputLabel} htmlFor='durationOverride'>
|
<label className={style.inputLabel} htmlFor='durationOverride'>
|
||||||
Duration
|
Duration
|
||||||
@@ -113,10 +101,8 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
|
|||||||
id='durationOverride'
|
id='durationOverride'
|
||||||
name='durationOverride'
|
name='durationOverride'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={timerValidationHandler}
|
|
||||||
time={duration}
|
time={duration}
|
||||||
placeholder='Duration'
|
placeholder='Duration'
|
||||||
warning={warning.duration}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.timeSettings}>
|
<div className={style.timeSettings}>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { VStack } from '@chakra-ui/react';
|
import { VStack } from '@chakra-ui/react';
|
||||||
|
import { IoCloud } from '@react-icons/all-files/io5/IoCloud';
|
||||||
|
import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline';
|
||||||
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
import { IoColorWand } from '@react-icons/all-files/io5/IoColorWand';
|
||||||
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle';
|
||||||
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline';
|
||||||
@@ -31,6 +33,8 @@ interface MenuBarProps {
|
|||||||
onAboutOpen: () => void;
|
onAboutOpen: () => void;
|
||||||
isQuickStartOpen: boolean;
|
isQuickStartOpen: boolean;
|
||||||
onQuickStartOpen: () => void;
|
onQuickStartOpen: () => void;
|
||||||
|
isSheetsOpen: boolean;
|
||||||
|
onSheetsOpen: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonStyle = {
|
const buttonStyle = {
|
||||||
@@ -58,6 +62,8 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
onAboutOpen,
|
onAboutOpen,
|
||||||
isQuickStartOpen,
|
isQuickStartOpen,
|
||||||
onQuickStartOpen,
|
onQuickStartOpen,
|
||||||
|
isSheetsOpen,
|
||||||
|
onSheetsOpen,
|
||||||
} = props;
|
} = props;
|
||||||
const { isElectron, sendToElectron } = useElectronEvent();
|
const { isElectron, sendToElectron } = useElectronEvent();
|
||||||
|
|
||||||
@@ -174,6 +180,16 @@ const MenuBar = (props: MenuBarProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={style.gap} />
|
<div className={style.gap} />
|
||||||
|
<TooltipActionBtn
|
||||||
|
{...buttonStyle}
|
||||||
|
isDisabled={appMode === AppMode.Run}
|
||||||
|
icon={isSheetsOpen ? <IoCloud /> : <IoCloudOutline />}
|
||||||
|
className={isSheetsOpen ? style.open : ''}
|
||||||
|
clickHandler={onSheetsOpen}
|
||||||
|
tooltip='Sheets'
|
||||||
|
aria-label='Sheets'
|
||||||
|
size='sm'
|
||||||
|
/>
|
||||||
<TooltipActionBtn
|
<TooltipActionBtn
|
||||||
{...buttonStyle}
|
{...buttonStyle}
|
||||||
isDisabled={appMode === AppMode.Run}
|
isDisabled={appMode === AppMode.Run}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r
|
|||||||
|
|
||||||
import ModalWrapper from '../ModalWrapper';
|
import ModalWrapper from '../ModalWrapper';
|
||||||
|
|
||||||
import OscIntegration from './OscIntegration';
|
import HttpIntegration from './http/HttpIntegration';
|
||||||
import OscSettings from './OscSettings';
|
import OscIntegration from './osc/OscIntegration';
|
||||||
|
import OscSettings from './osc/OscSettings';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../Modal.module.scss';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export default function IntegrationModal(props: IntegrationModalProps) {
|
|||||||
<TabList>
|
<TabList>
|
||||||
<Tab>OSC</Tab>
|
<Tab>OSC</Tab>
|
||||||
<Tab>OSC Integration</Tab>
|
<Tab>OSC Integration</Tab>
|
||||||
|
<Tab>HTTP Integration</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
@@ -38,6 +40,9 @@ export default function IntegrationModal(props: IntegrationModalProps) {
|
|||||||
<TabPanel>
|
<TabPanel>
|
||||||
<OscIntegration />
|
<OscIntegration />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<HttpIntegration />
|
||||||
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { Switch } from '@chakra-ui/react';
|
||||||
|
import type { HttpSettings } from 'ontime-types';
|
||||||
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
|
||||||
|
import { useEmitLog } from '../../../../common/stores/logger';
|
||||||
|
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||||
|
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||||
|
import { OntimeCycle, sectionText } from '../integration.utils';
|
||||||
|
|
||||||
|
import HttpSubscriptionRow from './HttpSubscriptionRow';
|
||||||
|
|
||||||
|
import styles from '../../Modal.module.scss';
|
||||||
|
|
||||||
|
export default function HttpIntegration() {
|
||||||
|
const { data, isFetching } = useHttpSettings();
|
||||||
|
const { mutateAsync } = usePostHttpSettings();
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
register,
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitting, isDirty, isValid },
|
||||||
|
} = useForm<HttpSettings>({
|
||||||
|
mode: 'onBlur',
|
||||||
|
defaultValues: data,
|
||||||
|
values: data,
|
||||||
|
resetOptions: {
|
||||||
|
keepDirtyValues: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
reset(data);
|
||||||
|
}
|
||||||
|
}, [data, reset]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
reset(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: HttpSettings) => {
|
||||||
|
try {
|
||||||
|
const newSettings: HttpSettings = {
|
||||||
|
enabledOut: Boolean(values.enabledOut),
|
||||||
|
subscriptions: {
|
||||||
|
onLoad: values.subscriptions.onLoad ?? [],
|
||||||
|
onStart: values.subscriptions.onStart ?? [],
|
||||||
|
onPause: values.subscriptions.onPause ?? [],
|
||||||
|
onStop: values.subscriptions.onStop ?? [],
|
||||||
|
onUpdate: values.subscriptions.onUpdate ?? [],
|
||||||
|
onFinish: values.subscriptions.onFinish ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mutateAsync(newSettings);
|
||||||
|
} catch (error) {
|
||||||
|
emitError(`Error setting HTML: ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isFetching) {
|
||||||
|
return <ModalLoader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholder = 'http://x.x.x.x:xxxx/api/path';
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
|
||||||
|
<div className={styles.splitSection}>
|
||||||
|
<div>
|
||||||
|
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
|
||||||
|
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||||
|
</div>
|
||||||
|
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onLoad}
|
||||||
|
title={sectionText.onLoad.title}
|
||||||
|
subtitle={sectionText.onLoad.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onLoad}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onStart}
|
||||||
|
title={sectionText.onStart.title}
|
||||||
|
subtitle={sectionText.onStart.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onStart}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onPause}
|
||||||
|
title={sectionText.onPause.title}
|
||||||
|
subtitle={sectionText.onPause.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onPause}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onStop}
|
||||||
|
title={sectionText.onStop.title}
|
||||||
|
subtitle={sectionText.onStop.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onStop}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onUpdate}
|
||||||
|
title={sectionText.onUpdate.title}
|
||||||
|
subtitle={sectionText.onUpdate.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onUpdate}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<HttpSubscriptionRow
|
||||||
|
cycle={TimerLifeCycle.onFinish}
|
||||||
|
title={sectionText.onFinish.title}
|
||||||
|
subtitle={sectionText.onFinish.subtitle}
|
||||||
|
visible={showSection === TimerLifeCycle.onFinish}
|
||||||
|
setShowSection={setShowSection}
|
||||||
|
register={register}
|
||||||
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<OntimeModalFooter
|
||||||
|
formId='http-subscriptions'
|
||||||
|
handleRevert={resetForm}
|
||||||
|
isDirty={isDirty}
|
||||||
|
isValid={isValid}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
|
||||||
|
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||||
|
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||||
|
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||||
|
import { HttpSettings, TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
|
import { useEmitLog } from '../../../../common/stores/logger';
|
||||||
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
|
|
||||||
|
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
|
||||||
|
import styles from '../../Modal.module.scss';
|
||||||
|
|
||||||
|
interface SubscriptionRowProps {
|
||||||
|
cycle: TimerLifeCycle;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
visible: boolean;
|
||||||
|
setShowSection: (cycle: TimerLifeCycle) => void;
|
||||||
|
register: UseFormRegister<HttpSettings>;
|
||||||
|
control: Control<HttpSettings>;
|
||||||
|
placeholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionRow(props: SubscriptionRowProps) {
|
||||||
|
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
|
||||||
|
const { emitError } = useEmitLog();
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
name: `subscriptions.${cycle}`,
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasTooManyOptions = fields.length >= 3;
|
||||||
|
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
|
||||||
|
|
||||||
|
const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`;
|
||||||
|
|
||||||
|
const handleAddNew = () => {
|
||||||
|
if (hasTooManyOptions) {
|
||||||
|
emitError(`Maximum amount of ${cycle} subscriptions reached (3)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
append({
|
||||||
|
message: '',
|
||||||
|
enabled: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||||
|
<div>
|
||||||
|
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
|
||||||
|
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
<FiChevronUp className={visible ? collapseStyles.moreCollapsed : collapseStyles.moreExpanded} />
|
||||||
|
</div>
|
||||||
|
{visible && (
|
||||||
|
<>
|
||||||
|
{fields.map((subscription, index) => (
|
||||||
|
<div key={subscription.id} className={styles.entryRow}>
|
||||||
|
<IconButton
|
||||||
|
icon={<IoRemove />}
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
aria-label='delete'
|
||||||
|
size='xs'
|
||||||
|
colorScheme='red'
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
size='xs'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
autoComplete='off'
|
||||||
|
{...register(`subscriptions.${cycle}.${index}.message`, {
|
||||||
|
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={handleAddNew}
|
||||||
|
className={styles.shiftRight}
|
||||||
|
isDisabled={hasTooManyOptions}
|
||||||
|
size='xs'
|
||||||
|
colorScheme='blue'
|
||||||
|
variant='outline'
|
||||||
|
padding='0 2em'
|
||||||
|
>
|
||||||
|
Add new
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
|
export type OntimeCycle = keyof typeof TimerLifeCycle;
|
||||||
|
|
||||||
|
export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
|
||||||
|
onLoad: {
|
||||||
|
title: 'On Load',
|
||||||
|
subtitle: 'Triggers when a timer is loaded',
|
||||||
|
},
|
||||||
|
onStart: {
|
||||||
|
title: 'On Start',
|
||||||
|
subtitle: 'Triggers when a timer starts',
|
||||||
|
},
|
||||||
|
onPause: {
|
||||||
|
title: 'On Pause',
|
||||||
|
subtitle: 'Triggers when a running timer is paused',
|
||||||
|
},
|
||||||
|
onStop: {
|
||||||
|
title: 'On Stop',
|
||||||
|
subtitle: 'Triggers when a running timer is stopped',
|
||||||
|
},
|
||||||
|
onUpdate: {
|
||||||
|
title: 'On Every Second',
|
||||||
|
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
|
||||||
|
},
|
||||||
|
onFinish: {
|
||||||
|
title: 'On Finish',
|
||||||
|
subtitle: 'Triggers when a running reaches 0',
|
||||||
|
},
|
||||||
|
};
|
||||||
+13
-34
@@ -3,43 +3,15 @@ import { useForm } from 'react-hook-form';
|
|||||||
import type { OscSubscription } from 'ontime-types';
|
import type { OscSubscription } from 'ontime-types';
|
||||||
import { TimerLifeCycle } from 'ontime-types';
|
import { TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
import useOscSettings, { usePostOscSubscriptions } from '../../../common/hooks-query/useOscSettings';
|
import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../../common/stores/logger';
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||||
import OntimeModalFooter from '../OntimeModalFooter';
|
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||||
|
import { type OntimeCycle, sectionText } from '../integration.utils';
|
||||||
|
|
||||||
import OscSubscriptionRow from './OscSubscriptionRow';
|
import OscSubscriptionRow from './OscSubscriptionRow';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../../Modal.module.scss';
|
||||||
|
|
||||||
type OntimeCycle = keyof typeof TimerLifeCycle;
|
|
||||||
|
|
||||||
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
|
|
||||||
onLoad: {
|
|
||||||
title: 'On Load',
|
|
||||||
subtitle: 'Triggers when a timer is loaded',
|
|
||||||
},
|
|
||||||
onStart: {
|
|
||||||
title: 'On Start',
|
|
||||||
subtitle: 'Triggers when a timer starts',
|
|
||||||
},
|
|
||||||
onPause: {
|
|
||||||
title: 'On Pause',
|
|
||||||
subtitle: 'Triggers when a running timer is paused',
|
|
||||||
},
|
|
||||||
onStop: {
|
|
||||||
title: 'On Stop',
|
|
||||||
subtitle: 'Triggers when a running timer is stopped',
|
|
||||||
},
|
|
||||||
onUpdate: {
|
|
||||||
title: 'On Every Second',
|
|
||||||
subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)',
|
|
||||||
},
|
|
||||||
onFinish: {
|
|
||||||
title: 'On Finish',
|
|
||||||
subtitle: 'Triggers when a running reaches 0',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OscIntegration() {
|
export default function OscIntegration() {
|
||||||
const { data, isFetching } = useOscSettings();
|
const { data, isFetching } = useOscSettings();
|
||||||
@@ -92,6 +64,7 @@ export default function OscIntegration() {
|
|||||||
return <ModalLoader />;
|
return <ModalLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placeholder = 'OSC message';
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
|
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
@@ -102,6 +75,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
cycle={TimerLifeCycle.onStart}
|
cycle={TimerLifeCycle.onStart}
|
||||||
@@ -111,6 +85,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
cycle={TimerLifeCycle.onPause}
|
cycle={TimerLifeCycle.onPause}
|
||||||
@@ -120,6 +95,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
cycle={TimerLifeCycle.onStop}
|
cycle={TimerLifeCycle.onStop}
|
||||||
@@ -129,6 +105,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
cycle={TimerLifeCycle.onUpdate}
|
cycle={TimerLifeCycle.onUpdate}
|
||||||
@@ -138,6 +115,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OscSubscriptionRow
|
<OscSubscriptionRow
|
||||||
cycle={TimerLifeCycle.onFinish}
|
cycle={TimerLifeCycle.onFinish}
|
||||||
@@ -147,6 +125,7 @@ export default function OscIntegration() {
|
|||||||
setShowSection={setShowSection}
|
setShowSection={setShowSection}
|
||||||
register={register}
|
register={register}
|
||||||
control={control}
|
control={control}
|
||||||
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
<OntimeModalFooter
|
<OntimeModalFooter
|
||||||
formId='osc-subscriptions'
|
formId='osc-subscriptions'
|
||||||
+7
-10
@@ -1,17 +1,15 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-nocheck -- working on it
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
import { FormControl, Input, Switch } from '@chakra-ui/react';
|
||||||
|
|
||||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
|
||||||
import { PlaceholderSettings } from '../../../common/models/OscSettings';
|
import { PlaceholderSettings } from '../../../../common/models/OscSettings';
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../../common/stores/logger';
|
||||||
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
|
import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex';
|
||||||
import ModalLoader from '../modal-loader/ModalLoader';
|
import ModalLoader from '../../modal-loader/ModalLoader';
|
||||||
import OntimeModalFooter from '../OntimeModalFooter';
|
import OntimeModalFooter from '../../OntimeModalFooter';
|
||||||
|
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../../Modal.module.scss';
|
||||||
|
|
||||||
export default function OscSettings() {
|
export default function OscSettings() {
|
||||||
const { data, isFetching } = useOscSettings();
|
const { data, isFetching } = useOscSettings();
|
||||||
@@ -59,7 +57,6 @@ export default function OscSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
// @ts-expect-error -- we know the types dont match
|
|
||||||
reset(data);
|
reset(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
+7
-6
@@ -2,12 +2,12 @@ import { Control, useFieldArray, UseFormRegister } from 'react-hook-form';
|
|||||||
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||||
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||||
import { OscSubscription, TimerLifeCycle } from 'ontime-types';
|
import type { OscSubscription, TimerLifeCycle } from 'ontime-types';
|
||||||
|
|
||||||
import { useEmitLog } from '../../../common/stores/logger';
|
import { useEmitLog } from '../../../../common/stores/logger';
|
||||||
|
|
||||||
import collapseStyles from '../../../common/components/collapse-bar/CollapseBar.module.scss';
|
import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss';
|
||||||
import styles from '../Modal.module.scss';
|
import styles from '../../Modal.module.scss';
|
||||||
|
|
||||||
interface OscSubscriptionRowProps {
|
interface OscSubscriptionRowProps {
|
||||||
cycle: TimerLifeCycle;
|
cycle: TimerLifeCycle;
|
||||||
@@ -17,10 +17,11 @@ interface OscSubscriptionRowProps {
|
|||||||
setShowSection: (cycle: TimerLifeCycle) => void;
|
setShowSection: (cycle: TimerLifeCycle) => void;
|
||||||
register: UseFormRegister<OscSubscription>;
|
register: UseFormRegister<OscSubscription>;
|
||||||
control: Control<OscSubscription>;
|
control: Control<OscSubscription>;
|
||||||
|
placeholder: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
||||||
const { cycle, title, subtitle, visible, setShowSection, register, control } = props;
|
const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { fields, append, remove } = useFieldArray({
|
const { fields, append, remove } = useFieldArray({
|
||||||
name: cycle,
|
name: cycle,
|
||||||
@@ -64,7 +65,7 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
|||||||
colorScheme='red'
|
colorScheme='red'
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder='OSC Message'
|
placeholder={placeholder}
|
||||||
size='xs'
|
size='xs'
|
||||||
variant='ontime-filled-on-light'
|
variant='ontime-filled-on-light'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
@@ -119,6 +119,7 @@ export default function AppSettingsModal() {
|
|||||||
<option value='en'>English</option>
|
<option value='en'>English</option>
|
||||||
<option value='fr'>French</option>
|
<option value='fr'>French</option>
|
||||||
<option value='de'>German</option>
|
<option value='de'>German</option>
|
||||||
|
<option value='it'>Italian</option>
|
||||||
<option value='no'>Norwegian</option>
|
<option value='no'>Norwegian</option>
|
||||||
<option value='pt'>Portuguese</option>
|
<option value='pt'>Portuguese</option>
|
||||||
<option value='es'>Spanish</option>
|
<option value='es'>Spanish</option>
|
||||||
|
|||||||
@@ -0,0 +1,469 @@
|
|||||||
|
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertIcon,
|
||||||
|
AlertTitle,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
ModalOverlay,
|
||||||
|
Select,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { OntimeRundown, ProjectData, UserFields } from 'ontime-types';
|
||||||
|
import { defaultExcelImportMap, ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
import { PROJECT_DATA, RUNDOWN, USERFIELDS } from '../../../common/api/apiConstants';
|
||||||
|
import { maybeAxiosError } from '../../../common/api/apiUtils';
|
||||||
|
import {
|
||||||
|
getAuthentication,
|
||||||
|
getClientSecrect,
|
||||||
|
getSheetsAuthUrl,
|
||||||
|
patchData,
|
||||||
|
postId,
|
||||||
|
postPreviewSheet,
|
||||||
|
postPushSheet,
|
||||||
|
postWorksheet,
|
||||||
|
uploadSheetClientFile,
|
||||||
|
} from '../../../common/api/ontimeApi';
|
||||||
|
import { projectDataPlaceholder } from '../../../common/models/ProjectData';
|
||||||
|
import { userFieldsPlaceholder } from '../../../common/models/UserFields';
|
||||||
|
import { openLink } from '../../../common/utils/linkUtils';
|
||||||
|
import ModalLink from '../ModalLink';
|
||||||
|
import PreviewExcel from '../upload-modal/preview/PreviewExcel';
|
||||||
|
import ExcelFileOptions from '../upload-modal/upload-options/ExcelFileOptions';
|
||||||
|
|
||||||
|
import Step from './Step';
|
||||||
|
|
||||||
|
interface SheetsModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SheetsModal(props: SheetsModalProps) {
|
||||||
|
const { isOpen, onClose } = props;
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [rundown, setRundown] = useState<OntimeRundown | null>(null);
|
||||||
|
const [userFields, setUserFields] = useState<UserFields | null>(null);
|
||||||
|
const [project, setProject] = useState<ProjectData | null>(null);
|
||||||
|
|
||||||
|
const [id, setSheetId] = useState('');
|
||||||
|
const [worksheet, setWorksheet] = useState('');
|
||||||
|
const [worksheetOptions, setWorksheetOptions] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [direction, setDirection] = useState('none');
|
||||||
|
const excelFileOptions = useRef<ExcelImportMap>(defaultExcelImportMap);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [state, setState] = useState({
|
||||||
|
clientSecret: { complete: false, message: '' },
|
||||||
|
authenticate: { complete: false, message: '' },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setDirection('none');
|
||||||
|
testClientSecret();
|
||||||
|
if (state.clientSecret.complete) testAuthentication();
|
||||||
|
if (state.authenticate.complete) testSheetId();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setRundown(null);
|
||||||
|
setProject(null);
|
||||||
|
setUserFields(null);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
//SETP-1 Upload Client ID
|
||||||
|
const handleClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!event.target.files?.length) {
|
||||||
|
setState({
|
||||||
|
clientSecret: { complete: false, message: 'Missing file' },
|
||||||
|
authenticate: { complete: false, message: '' },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selectedFile = event.target.files[0];
|
||||||
|
uploadSheetClientFile(selectedFile)
|
||||||
|
.then(() => {
|
||||||
|
setState({ ...state, clientSecret: { complete: true, message: '' } });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = maybeAxiosError(err);
|
||||||
|
setState({
|
||||||
|
clientSecret: { complete: false, message },
|
||||||
|
authenticate: { complete: false, message: '' },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testClientSecret = () => {
|
||||||
|
getClientSecrect()
|
||||||
|
.then(() => {
|
||||||
|
setState({ ...state, clientSecret: { complete: true, message: '' } });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = maybeAxiosError(err);
|
||||||
|
setState({
|
||||||
|
clientSecret: { complete: false, message },
|
||||||
|
authenticate: { complete: false, message: '' },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//SETP-2 Authenticate
|
||||||
|
const handleAuthenticate = () => {
|
||||||
|
getSheetsAuthUrl()
|
||||||
|
.then((data) => {
|
||||||
|
openLink(data);
|
||||||
|
window.addEventListener('focus', () => testAuthentication(), { once: true });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = maybeAxiosError(err);
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
authenticate: { complete: false, message },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const testAuthentication = () => {
|
||||||
|
getAuthentication()
|
||||||
|
.then(() => {
|
||||||
|
setState({ ...state, authenticate: { complete: true, message: '' } });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
authenticate: { complete: false, message },
|
||||||
|
id: { complete: false, message: '' },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//SETP-3 set sheet ID
|
||||||
|
const testSheetId = () => {
|
||||||
|
postId(id)
|
||||||
|
.then((data) => {
|
||||||
|
setState({ ...state, id: { complete: true, message: '' } });
|
||||||
|
setWorksheetOptions(data.worksheetOptions);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = maybeAxiosError(err);
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
id: { complete: false, message },
|
||||||
|
worksheet: { complete: false, message: '' },
|
||||||
|
pullPush: { complete: false, message: '' },
|
||||||
|
});
|
||||||
|
setWorksheetOptions([]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//SETP-4 Select Worksheet
|
||||||
|
const testWorksheet = (value: string) => {
|
||||||
|
excelFileOptions.current.worksheet = value;
|
||||||
|
setWorksheet(value);
|
||||||
|
postWorksheet(id, worksheet)
|
||||||
|
.then(() => {
|
||||||
|
setState({ ...state, worksheet: { complete: true, message: '' } });
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
const message = maybeAxiosError(err);
|
||||||
|
setState({ ...state, worksheet: { complete: false, message }, pullPush: { complete: false, message: '' } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//SETP-5 Upload / Download
|
||||||
|
const updateExcelFileOptions = <T extends keyof ExcelImportMap>(field: T, value: ExcelImportMap[T]) => {
|
||||||
|
if (excelFileOptions.current[field] !== value) {
|
||||||
|
excelFileOptions.current = { ...excelFileOptions.current, [field]: value };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePullData = () => {
|
||||||
|
postPreviewSheet(id, excelFileOptions.current)
|
||||||
|
.then((data) => {
|
||||||
|
setProject(data.project);
|
||||||
|
setRundown(data.rundown);
|
||||||
|
setUserFields(data.userFields);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setDirection('none');
|
||||||
|
setState({ ...state, pullPush: { complete: false, message } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePushData = () => {
|
||||||
|
postPushSheet(id, excelFileOptions.current)
|
||||||
|
.then(() => {
|
||||||
|
setDirection('none');
|
||||||
|
setState({ ...state, pullPush: { complete: true, message: '' } });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
setDirection('none');
|
||||||
|
setState({ ...state, pullPush: { complete: false, message } });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
//GET preview
|
||||||
|
const handleFinalise = async () => {
|
||||||
|
if (rundown && userFields && project) {
|
||||||
|
let doClose = false;
|
||||||
|
try {
|
||||||
|
await patchData({ rundown, userFields, project });
|
||||||
|
queryClient.setQueryData(RUNDOWN, rundown);
|
||||||
|
queryClient.setQueryData(USERFIELDS, userFields);
|
||||||
|
queryClient.setQueryData(PROJECT_DATA, project);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: [...RUNDOWN, ...USERFIELDS, ...PROJECT_DATA],
|
||||||
|
});
|
||||||
|
doClose = true;
|
||||||
|
} catch (error) {
|
||||||
|
const message = maybeAxiosError(error);
|
||||||
|
console.error(message);
|
||||||
|
} finally {
|
||||||
|
if (doClose) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onClose={handleClose}
|
||||||
|
isOpen={isOpen}
|
||||||
|
closeOnOverlayClick={false}
|
||||||
|
motionPreset='slideInBottom'
|
||||||
|
size='xl'
|
||||||
|
scrollBehavior='inside'
|
||||||
|
preserveScrollBarGap
|
||||||
|
variant='ontime-upload'
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Rundown from sheets (experimental)</ModalHeader>
|
||||||
|
<ModalCloseButton />
|
||||||
|
<ModalBody>
|
||||||
|
<Alert status='info' variant='ontime-on-light-info'>
|
||||||
|
<AlertIcon />
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<AlertTitle>Sync with Google Sheets</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<ModalLink href='https://ontime.gitbook.io/v2/features/google-sheet'>
|
||||||
|
For more information, see the docs
|
||||||
|
</ModalLink>
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
{!rundown ? (
|
||||||
|
direction === 'up' || direction === 'down' ? (
|
||||||
|
<ExcelFileOptions optionsRef={excelFileOptions} updateOptions={updateExcelFileOptions} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Step
|
||||||
|
title='1 - Upload OAuth 2.0 Client ID'
|
||||||
|
completed={state.clientSecret.complete}
|
||||||
|
disabled={false}
|
||||||
|
error={state.clientSecret.message}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
type='file'
|
||||||
|
onChange={handleFile}
|
||||||
|
accept='.json'
|
||||||
|
data-testid='file-input'
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button size='sm' variant='ontime-subtle-on-light' onClick={handleClick}>
|
||||||
|
{state.clientSecret.complete ? 'Reupload Client ID' : 'Upload Client ID'}
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' variant='ontime-ghosted-on-light' onClick={testClientSecret}>
|
||||||
|
Retry Client ID
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title='2 - Authenticate with Google'
|
||||||
|
completed={state.authenticate.complete}
|
||||||
|
disabled={!state.clientSecret.complete}
|
||||||
|
error={state.authenticate.message}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
onClick={handleAuthenticate}
|
||||||
|
isDisabled={!state.clientSecret.complete}
|
||||||
|
>
|
||||||
|
Authenticate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-ghosted-on-light'
|
||||||
|
onClick={testAuthentication}
|
||||||
|
isDisabled={!state.clientSecret.complete}
|
||||||
|
>
|
||||||
|
Retry Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title='3 - Add Document ID'
|
||||||
|
completed={state.id.complete}
|
||||||
|
disabled={!state.authenticate.complete}
|
||||||
|
error={state.id.message}
|
||||||
|
>
|
||||||
|
<HStack>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-filled-on-light'
|
||||||
|
isDisabled={!state.authenticate.complete}
|
||||||
|
value={id}
|
||||||
|
onChange={(event) => setSheetId(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
isDisabled={!state.authenticate.complete}
|
||||||
|
size='sm'
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
padding='0 2em'
|
||||||
|
onClick={testSheetId}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title='4 - Select Worksheet to import'
|
||||||
|
completed={state.worksheet.complete}
|
||||||
|
disabled={worksheetOptions.length == 0}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size='sm'
|
||||||
|
isDisabled={worksheetOptions.length == 0}
|
||||||
|
placeholder='Select a worksheet'
|
||||||
|
onChange={(event) => testWorksheet(event.target.value)}
|
||||||
|
value={worksheet}
|
||||||
|
>
|
||||||
|
{worksheetOptions.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step title='5 - Upload / Download rundown' completed={false} disabled={!state.worksheet.complete}>
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button
|
||||||
|
isDisabled={!state.worksheet.complete}
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
padding='0 2em'
|
||||||
|
onClick={() => setDirection('up')}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isDisabled={!state.worksheet.complete}
|
||||||
|
variant='ontime-subtle-on-light'
|
||||||
|
padding='0 2em'
|
||||||
|
onClick={() => setDirection('down')}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Step>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<PreviewExcel
|
||||||
|
rundown={rundown ?? []}
|
||||||
|
project={project ?? projectDataPlaceholder}
|
||||||
|
userFields={userFields ?? userFieldsPlaceholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{rundown ? (
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setRundown(null);
|
||||||
|
setDirection('none');
|
||||||
|
}}
|
||||||
|
variant='ontime-ghost-on-light'
|
||||||
|
>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handleFinalise}>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : direction === 'up' ? (
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handlePushData}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : direction === 'down' ? (
|
||||||
|
<div style={{ display: 'flex', gap: '1em' }}>
|
||||||
|
<Button onClick={() => setDirection('none')} variant='ontime-ghost-on-light'>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button variant='ontime-filled' padding='0 2em' onClick={handlePullData}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@use '../../../theme/ontimeColours' as *;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: inline;
|
||||||
|
color: $gray-500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
color: $green-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorIcon {
|
||||||
|
color: $red-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
color: $red-700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { IoCheckmarkCircle } from '@react-icons/all-files/io5/IoCheckmarkCircle';
|
||||||
|
import { IoCloseCircle } from '@react-icons/all-files/io5/IoCloseCircle';
|
||||||
|
import { IoRadioButtonOffOutline } from '@react-icons/all-files/io5/IoRadioButtonOffOutline';
|
||||||
|
|
||||||
|
import style from './Step.module.scss';
|
||||||
|
|
||||||
|
interface StepProps {
|
||||||
|
title: string;
|
||||||
|
disabled: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Step(props: PropsWithChildren<StepProps>) {
|
||||||
|
const { title, disabled, completed, error, children } = props;
|
||||||
|
const [collapsed, setCollapsed] = useState(disabled);
|
||||||
|
|
||||||
|
const handleCollapse = () => setCollapsed((prev) => !prev);
|
||||||
|
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
if (completed) return <IoCheckmarkCircle className={style.step} style={{ color: 'green' }} />;
|
||||||
|
if (error) return <IoCloseCircle className={style.step} style={{ color: 'red' }} />;
|
||||||
|
return <IoRadioButtonOffOutline className={style.step} />;
|
||||||
|
}, [completed, error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (completed) {
|
||||||
|
setCollapsed(true);
|
||||||
|
}
|
||||||
|
}, [completed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={style.wrapper}>
|
||||||
|
<div className={style.header} onClick={handleCollapse}>
|
||||||
|
{icon}
|
||||||
|
<span className={style.title}>{title}</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
{error && <div className={style.errorText}>{error}</div>}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import useFollowComponent from '../../common/hooks/useFollowComponent';
|
|||||||
import { useOperator } from '../../common/hooks/useSocket';
|
import { useOperator } from '../../common/hooks/useSocket';
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
|
import useSettings from '../../common/hooks-query/useSettings';
|
||||||
import useUserFields from '../../common/hooks-query/useUserFields';
|
import useUserFields from '../../common/hooks-query/useUserFields';
|
||||||
import { debounce } from '../../common/utils/debounce';
|
import { debounce } from '../../common/utils/debounce';
|
||||||
import { isStringBoolean } from '../../common/utils/viewUtils';
|
import { isStringBoolean } from '../../common/utils/viewUtils';
|
||||||
@@ -40,6 +41,7 @@ export default function Operator() {
|
|||||||
|
|
||||||
const featureData = useOperator();
|
const featureData = useOperator();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
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<PartialEdit | null>(null);
|
||||||
@@ -121,13 +123,16 @@ export default function Operator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// get fields which the user subscribed to
|
// get fields which the user subscribed to
|
||||||
|
const shouldEdit = searchParams.get('shouldEdit');
|
||||||
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
const subscribe = searchParams.get('subscribe') as keyof UserFields | null;
|
||||||
|
const canEdit = shouldEdit && subscribe;
|
||||||
|
|
||||||
const main = searchParams.get('main') as keyof TitleFields | null;
|
const main = searchParams.get('main') as keyof TitleFields | null;
|
||||||
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
|
const secondary = searchParams.get('secondary') as keyof TitleFields | null;
|
||||||
const subscribedAlias = subscribe ? userFields[subscribe] : '';
|
const subscribedAlias = subscribe ? userFields[subscribe] : '';
|
||||||
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
const showSeconds = isStringBoolean(searchParams.get('showseconds'));
|
||||||
|
|
||||||
const operatorOptions = getOperatorOptions(userFields);
|
const operatorOptions = getOperatorOptions(userFields, settings?.timeFormat ?? '24');
|
||||||
let isPast = Boolean(featureData.selectedEventId);
|
let isPast = Boolean(featureData.selectedEventId);
|
||||||
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
const hidePast = isStringBoolean(searchParams.get('hidepast'));
|
||||||
|
|
||||||
@@ -150,7 +155,7 @@ export default function Operator() {
|
|||||||
lastId={lastEvent?.id}
|
lastId={lastEvent?.id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{subscribe && (
|
{canEdit && (
|
||||||
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
|
<div className={`${style.editPrompt} ${showEditPrompt ? style.show : undefined}`}>
|
||||||
Press and hold to edit user field
|
Press and hold to edit user field
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +196,7 @@ export default function Operator() {
|
|||||||
showSeconds={showSeconds}
|
showSeconds={showSeconds}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
selectedRef={isSelected ? selectedRef : undefined}
|
selectedRef={isSelected ? selectedRef : undefined}
|
||||||
onLongPress={subscribe ? handleEdit : () => undefined}
|
onLongPress={canEdit ? handleEdit : () => undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,7 +198,9 @@ export default function Rundown(props: RundownProps) {
|
|||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
eventIndex = 0;
|
eventIndex = 0;
|
||||||
}
|
}
|
||||||
|
let isFirstEvent = false;
|
||||||
if (entry.type === SupportedEvent.Event) {
|
if (entry.type === SupportedEvent.Event) {
|
||||||
|
isFirstEvent = eventIndex === 0;
|
||||||
eventIndex++;
|
eventIndex++;
|
||||||
previousEnd = thisEnd;
|
previousEnd = thisEnd;
|
||||||
thisEnd = entry.timeEnd;
|
thisEnd = entry.timeEnd;
|
||||||
@@ -220,6 +222,7 @@ export default function Rundown(props: RundownProps) {
|
|||||||
<RundownEntry
|
<RundownEntry
|
||||||
type={entry.type}
|
type={entry.type}
|
||||||
isPast={isPast}
|
isPast={isPast}
|
||||||
|
isFirstEvent={isFirstEvent}
|
||||||
data={entry}
|
data={entry}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
hasCursor={hasCursor}
|
hasCursor={hasCursor}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
|
|||||||
interface RundownEntryProps {
|
interface RundownEntryProps {
|
||||||
type: SupportedEvent;
|
type: SupportedEvent;
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
|
isFirstEvent: boolean;
|
||||||
data: OntimeRundownEntry;
|
data: OntimeRundownEntry;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
hasCursor: boolean;
|
hasCursor: boolean;
|
||||||
@@ -31,8 +32,19 @@ interface RundownEntryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RundownEntry(props: RundownEntryProps) {
|
export default function RundownEntry(props: RundownEntryProps) {
|
||||||
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
|
const {
|
||||||
props;
|
isPast,
|
||||||
|
data,
|
||||||
|
selected,
|
||||||
|
hasCursor,
|
||||||
|
next,
|
||||||
|
previousEnd,
|
||||||
|
previousEventId,
|
||||||
|
playback,
|
||||||
|
isRolling,
|
||||||
|
disableEdit,
|
||||||
|
isFirstEvent,
|
||||||
|
} = props;
|
||||||
const { emitError } = useEmitLog();
|
const { emitError } = useEmitLog();
|
||||||
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
|
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
|
||||||
|
|
||||||
@@ -100,7 +112,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
}
|
}
|
||||||
case 'clone': {
|
case 'clone': {
|
||||||
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
const newEvent = cloneEvent(data as OntimeEvent, data.id);
|
||||||
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? []
|
const rundown = ontimeQueryClient.getQueryData<GetRundownCached>(RUNDOWN)?.rundown ?? [];
|
||||||
newEvent.cue = getCueCandidate(rundown, data.id);
|
newEvent.cue = getCueCandidate(rundown, data.id);
|
||||||
addEvent(newEvent);
|
addEvent(newEvent);
|
||||||
break;
|
break;
|
||||||
@@ -176,6 +188,7 @@ export default function RundownEntry(props: RundownEntryProps) {
|
|||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
actionHandler={actionHandler}
|
actionHandler={actionHandler}
|
||||||
disableEdit={disableEdit}
|
disableEdit={disableEdit}
|
||||||
|
isFirstEvent={isFirstEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (data.type === SupportedEvent.Block) {
|
} else if (data.type === SupportedEvent.Block) {
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ $skip-opacity: 0.1;
|
|||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"binder ... ... ..."
|
'binder ... ... ...'
|
||||||
"binder pb-actions times actions"
|
'binder pb-actions times actions'
|
||||||
"binder pb-actions title next"
|
'binder pb-actions title next-ind'
|
||||||
"binder pb-actions estatus estatus"
|
'binder pb-actions estatus estatus'
|
||||||
"binder ... ... ...";
|
'binder ... ... ...';
|
||||||
|
|
||||||
grid-template-columns: $block-binder-width auto 1fr auto;
|
grid-template-columns: $block-binder-width auto 1fr auto;
|
||||||
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
|
grid-template-rows: 0.25rem 2.25rem 2.25rem 2.25rem 0.25rem;
|
||||||
@@ -53,8 +53,18 @@ $skip-opacity: 0.1;
|
|||||||
outline: 1px solid $block-cursor-color;
|
outline: 1px solid $block-cursor-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* we stop the eventActions from having opacity to fix issue with dropdown drawing order */
|
||||||
&.past:not(.skip) {
|
&.past:not(.skip) {
|
||||||
opacity: 0.6;
|
.delayNote,
|
||||||
|
.statusElements,
|
||||||
|
.eventTitle,
|
||||||
|
.eventNote,
|
||||||
|
.eventTimers,
|
||||||
|
.eventStatus,
|
||||||
|
.playbackActions,
|
||||||
|
.binder {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.skip {
|
&.skip {
|
||||||
@@ -160,8 +170,8 @@ $skip-opacity: 0.1;
|
|||||||
grid-area: estatus;
|
grid-area: estatus;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"notes status"
|
'notes status'
|
||||||
"progb progb";
|
'progb progb';
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
grid-template-rows: auto 0.25rem;
|
grid-template-rows: auto 0.25rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -180,9 +190,8 @@ $skip-opacity: 0.1;
|
|||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.nextTag {
|
.nextTag {
|
||||||
grid-area: next;
|
grid-area: next-ind;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: $orange-500;
|
color: $orange-500;
|
||||||
letter-spacing: 0.03px;
|
letter-spacing: 0.03px;
|
||||||
@@ -190,6 +199,31 @@ $skip-opacity: 0.1;
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicators {
|
||||||
|
grid-area: next-ind;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0.4rem;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 0.7rem;
|
||||||
|
width: 0.7rem;
|
||||||
|
height: 0.7rem;
|
||||||
|
}
|
||||||
|
.indicator.delay {
|
||||||
|
background-color: $ontime-delay;
|
||||||
|
}
|
||||||
|
.indicator.nextDay,
|
||||||
|
.indicator.overlap,
|
||||||
|
.indicator.spacing {
|
||||||
|
background-color: $gray-600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.eventStatus {
|
.eventStatus {
|
||||||
grid-area: status;
|
grid-area: status;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -198,7 +232,6 @@ $skip-opacity: 0.1;
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: var(--status-color-override, $gray-500);
|
color: var(--status-color-override, $gray-500);
|
||||||
|
|
||||||
|
|
||||||
.statusIcon {
|
.statusIcon {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ interface EventBlockProps {
|
|||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
disableEdit: boolean;
|
disableEdit: boolean;
|
||||||
|
isFirstEvent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventBlock(props: EventBlockProps) {
|
export default function EventBlock(props: EventBlockProps) {
|
||||||
@@ -76,6 +77,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isRolling,
|
isRolling,
|
||||||
actionHandler,
|
actionHandler,
|
||||||
disableEdit,
|
disableEdit,
|
||||||
|
isFirstEvent,
|
||||||
} = props;
|
} = props;
|
||||||
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
|
||||||
const moveCursorTo = useAppMode((state) => state.setCursor);
|
const moveCursorTo = useAppMode((state) => state.setCursor);
|
||||||
@@ -210,6 +212,7 @@ export default function EventBlock(props: EventBlockProps) {
|
|||||||
isRolling={isRolling}
|
isRolling={isRolling}
|
||||||
actionHandler={actionHandler}
|
actionHandler={actionHandler}
|
||||||
disableEdit={disableEdit}
|
disableEdit={disableEdit}
|
||||||
|
isFirstEvent={isFirstEvent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import { IoPlaySkipForward } from '@react-icons/all-files/io5/IoPlaySkipForward'
|
|||||||
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
import { IoStop } from '@react-icons/all-files/io5/IoStop';
|
||||||
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
import { IoTime } from '@react-icons/all-files/io5/IoTime';
|
||||||
import { EndAction, Playback, TimerType } from 'ontime-types';
|
import { EndAction, Playback, TimerType } from 'ontime-types';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
import TooltipActionBtn from '../../../common/components/buttons/TooltipActionBtn';
|
||||||
import { useAppMode } from '../../../common/stores/appModeStore';
|
import { useAppMode } from '../../../common/stores/appModeStore';
|
||||||
|
import { millisToDelayString } from '../../../common/utils/dateConfig';
|
||||||
import { tooltipDelayMid } from '../../../ontimeConfig';
|
import { tooltipDelayMid } from '../../../ontimeConfig';
|
||||||
import EditableBlockTitle from '../common/EditableBlockTitle';
|
import EditableBlockTitle from '../common/EditableBlockTitle';
|
||||||
import { EventItemActions } from '../RundownEntry';
|
import { EventItemActions } from '../RundownEntry';
|
||||||
@@ -53,6 +55,7 @@ interface EventBlockInnerProps {
|
|||||||
isRolling: boolean;
|
isRolling: boolean;
|
||||||
actionHandler: (action: EventItemActions, payload?: any) => void;
|
actionHandler: (action: EventItemActions, payload?: any) => void;
|
||||||
disableEdit: boolean;
|
disableEdit: boolean;
|
||||||
|
isFirstEvent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EventBlockInner = (props: EventBlockInnerProps) => {
|
const EventBlockInner = (props: EventBlockInnerProps) => {
|
||||||
@@ -76,6 +79,7 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
isRolling,
|
isRolling,
|
||||||
actionHandler,
|
actionHandler,
|
||||||
disableEdit,
|
disableEdit,
|
||||||
|
isFirstEvent,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [renderInner, setRenderInner] = useState(false);
|
const [renderInner, setRenderInner] = useState(false);
|
||||||
@@ -103,6 +107,19 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
playBtnStyles._hover = {};
|
playBtnStyles._hover = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delayedStart = Math.max(0, timeStart + delay);
|
||||||
|
const newTime = millisToString(delayedStart);
|
||||||
|
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
|
||||||
|
|
||||||
|
const overlap = previousEnd - timeStart;
|
||||||
|
const overlapTime = !isFirstEvent
|
||||||
|
? overlap > 0
|
||||||
|
? `Overlapping ${millisToDelayString(overlap)}`
|
||||||
|
: overlap < 0
|
||||||
|
? `Spacing ${millisToDelayString(overlap)}`
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
|
||||||
return !renderInner ? null : (
|
return !renderInner ? null : (
|
||||||
<>
|
<>
|
||||||
<EventBlockTimers
|
<EventBlockTimers
|
||||||
@@ -114,10 +131,35 @@ const EventBlockInner = (props: EventBlockInnerProps) => {
|
|||||||
previousEnd={previousEnd}
|
previousEnd={previousEnd}
|
||||||
/>
|
/>
|
||||||
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
<EditableBlockTitle title={title} eventId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||||
{next && (
|
{next ? (
|
||||||
<Tooltip label='Next event' {...tooltipProps}>
|
<Tooltip label='Next event' {...tooltipProps}>
|
||||||
<span className={style.nextTag}>UP NEXT</span>
|
<span className={style.nextTag}>UP NEXT</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<span className={style.indicators}>
|
||||||
|
{delayTime && (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
<div>
|
||||||
|
{delayTime} <br />
|
||||||
|
New Time: {newTime}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={`${style.indicator} ${style.delay}`} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{overlapTime && (
|
||||||
|
<Tooltip label={overlapTime}>
|
||||||
|
<div className={`${style.indicator} ${overlap > 0 ? style.overlap : style.spacing}`} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{timeStart > timeEnd && (
|
||||||
|
<Tooltip label='Start time is later than end'>
|
||||||
|
<div className={`${style.indicator} ${style.nextDay}`} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<EventBlockPlayback
|
<EventBlockPlayback
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { memo, useCallback, useState } from 'react';
|
import { memo } from 'react';
|
||||||
import { OntimeEvent } from 'ontime-types';
|
import { OntimeEvent } from 'ontime-types';
|
||||||
import { calculateDuration, millisToString } from 'ontime-utils';
|
import { calculateDuration, millisToString } from 'ontime-utils';
|
||||||
|
|
||||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||||
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
import { useEventAction } from '../../../../common/hooks/useEventAction';
|
||||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||||
import { TimeEntryField, validateEntry } from '../../../../common/utils/timesManager';
|
|
||||||
|
|
||||||
import style from '../EventBlock.module.scss';
|
import style from '../EventBlock.module.scss';
|
||||||
|
|
||||||
@@ -24,8 +23,6 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
|
|||||||
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
|
const { eventId, timeStart, timeEnd, duration, delay, previousEnd } = props;
|
||||||
const { updateEvent } = useEventAction();
|
const { updateEvent } = useEventAction();
|
||||||
|
|
||||||
const [warning, setWarnings] = useState({ start: '', end: '', duration: '' });
|
|
||||||
|
|
||||||
const handleSubmit = (field: TimeActions, value: number) => {
|
const handleSubmit = (field: TimeActions, value: number) => {
|
||||||
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
const newEventData: Partial<OntimeEvent> = { id: eventId };
|
||||||
switch (field) {
|
switch (field) {
|
||||||
@@ -49,21 +46,6 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
|
|||||||
updateEvent(newEventData);
|
updateEvent(newEventData);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Validates a time input against its pair
|
|
||||||
* @param {string} entry - field to validate: timeStart, timeEnd, durationOverride
|
|
||||||
* @param {number} val - field value
|
|
||||||
* @return {boolean}
|
|
||||||
*/
|
|
||||||
const handleValidation = useCallback(
|
|
||||||
(field: TimeEntryField, value: number) => {
|
|
||||||
const valid = validateEntry(field, value, timeStart, timeEnd);
|
|
||||||
setWarnings((prev) => ({ ...prev, ...valid.warnings }));
|
|
||||||
return valid.value;
|
|
||||||
},
|
|
||||||
[timeEnd, timeStart],
|
|
||||||
);
|
|
||||||
|
|
||||||
const delayedStart = Math.max(0, timeStart + delay);
|
const delayedStart = Math.max(0, timeStart + delay);
|
||||||
const newTime = millisToString(delayedStart);
|
const newTime = millisToString(delayedStart);
|
||||||
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
|
const delayTime = delay !== 0 ? millisToDelayString(delay) : null;
|
||||||
@@ -73,32 +55,26 @@ const EventBlockTimers = (props: EventBlockTimerProps) => {
|
|||||||
<TimeInput
|
<TimeInput
|
||||||
name='timeStart'
|
name='timeStart'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={handleValidation}
|
|
||||||
time={timeStart}
|
time={timeStart}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
placeholder='Start'
|
placeholder='Start'
|
||||||
previousEnd={previousEnd}
|
previousEnd={previousEnd}
|
||||||
warning={warning.start}
|
|
||||||
/>
|
/>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
name='timeEnd'
|
name='timeEnd'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={handleValidation}
|
|
||||||
time={timeEnd}
|
time={timeEnd}
|
||||||
delay={delay}
|
delay={delay}
|
||||||
placeholder='End'
|
placeholder='End'
|
||||||
previousEnd={previousEnd}
|
previousEnd={previousEnd}
|
||||||
warning={warning.end}
|
|
||||||
/>
|
/>
|
||||||
<TimeInput
|
<TimeInput
|
||||||
name='durationOverride'
|
name='durationOverride'
|
||||||
submitHandler={handleSubmit}
|
submitHandler={handleSubmit}
|
||||||
validationHandler={handleValidation}
|
|
||||||
time={duration}
|
time={duration}
|
||||||
delay={0}
|
delay={0}
|
||||||
placeholder='Duration'
|
placeholder='Duration'
|
||||||
previousEnd={previousEnd}
|
previousEnd={previousEnd}
|
||||||
warning={warning.duration}
|
|
||||||
/>
|
/>
|
||||||
{delayTime && (
|
{delayTime && (
|
||||||
<div className={style.delayNote}>
|
<div className={style.delayNote}>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ComponentType, useMemo } from 'react';
|
import { ComponentType, useMemo } from 'react';
|
||||||
import { TimeManagerType } from 'common/models/TimeManager.type';
|
import { TimeManagerType } from 'common/models/TimeManager.type';
|
||||||
import { Message, OntimeEvent, ProjectData, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
|
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, TimerMessage, ViewSettings } from 'ontime-types';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
|
|
||||||
import useProjectData from '../../common/hooks-query/useProjectData';
|
import useProjectData from '../../common/hooks-query/useProjectData';
|
||||||
import useRundown from '../../common/hooks-query/useRundown';
|
import useRundown from '../../common/hooks-query/useRundown';
|
||||||
|
import useSettings from '../../common/hooks-query/useSettings';
|
||||||
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
import useViewSettings from '../../common/hooks-query/useViewSettings';
|
||||||
import { runtime } from '../../common/stores/runtime';
|
import { runtime } from '../../common/stores/runtime';
|
||||||
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
import { useViewOptionsStore } from '../../common/stores/viewOptions';
|
||||||
@@ -27,6 +28,7 @@ type WithDataProps = {
|
|||||||
nextId: string | null;
|
nextId: string | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
const { data: rundownData } = useRundown();
|
const { data: rundownData } = useRundown();
|
||||||
const { data: project } = useProjectData();
|
const { data: project } = useProjectData();
|
||||||
const { data: viewSettings } = useViewSettings();
|
const { data: viewSettings } = useViewSettings();
|
||||||
|
const { data: settings } = useSettings();
|
||||||
|
|
||||||
const publicEvents = useMemo(() => {
|
const publicEvents = useMemo(() => {
|
||||||
if (Array.isArray(rundownData)) {
|
if (Array.isArray(rundownData)) {
|
||||||
@@ -104,6 +107,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
|
|||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
publicSelectedId={publicSelectedId}
|
publicSelectedId={publicSelectedId}
|
||||||
viewSettings={viewSettings}
|
viewSettings={viewSettings}
|
||||||
|
settings={settings}
|
||||||
nextId={nextId}
|
nextId={nextId}
|
||||||
general={project}
|
general={project}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Message, OntimeEvent, ProjectData, SupportedEvent, ViewSettings } from 'ontime-types';
|
import { Message, OntimeEvent, ProjectData, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||||
import { formatDisplay } from 'ontime-utils';
|
import { formatDisplay } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
@@ -11,7 +11,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { BACKSTAGE_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { getBackstageOptions } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
@@ -37,10 +37,12 @@ interface BackstageProps {
|
|||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Backstage(props: BackstageProps) {
|
export default function Backstage(props: BackstageProps) {
|
||||||
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings } = props;
|
const { isMirrored, publ, eventNow, eventNext, time, backstageEvents, selectedId, general, viewSettings, settings } =
|
||||||
|
props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [blinkClass, setBlinkClass] = useState(false);
|
const [blinkClass, setBlinkClass] = useState(false);
|
||||||
@@ -89,11 +91,12 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
|
||||||
|
const backstageOptions = getBackstageOptions(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
<div className={`backstage ${isMirrored ? 'mirror' : ''}`} data-testid='backstage-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={BACKSTAGE_OPTIONS} />
|
<ViewParamsEditor paramFields={backstageOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
@@ -137,7 +140,7 @@ export default function Backstage(props: BackstageProps) {
|
|||||||
{isNegative ? (
|
{isNegative ? (
|
||||||
<div className='aux-timers__value'>{expectedFinish}</div>
|
<div className='aux-timers__value'>{expectedFinish}</div>
|
||||||
) : (
|
) : (
|
||||||
<SuperscriptTime time={startedAt} className='aux-timers__value' />
|
<SuperscriptTime time={expectedFinish} className='aux-timers__value' />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='timer-gap' />
|
<div className='timer-gap' />
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ViewSettings } from 'ontime-types';
|
import { Settings, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { getClockOptions } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
@@ -18,6 +18,7 @@ interface ClockProps {
|
|||||||
isMirrored: boolean;
|
isMirrored: boolean;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatOptions = {
|
const formatOptions = {
|
||||||
@@ -26,7 +27,7 @@ const formatOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Clock(props: ClockProps) {
|
export default function Clock(props: ClockProps) {
|
||||||
const { isMirrored, time, viewSettings } = props;
|
const { isMirrored, time, viewSettings, settings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@@ -124,6 +125,8 @@ export default function Clock(props: ClockProps) {
|
|||||||
const clock = formatTime(time.clock, formatOptions);
|
const clock = formatTime(time.clock, formatOptions);
|
||||||
const clean = clock.replace('/:/g', '');
|
const clean = clock.replace('/:/g', '');
|
||||||
|
|
||||||
|
const clockOptions = getClockOptions(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
|
className={`clock-view ${isMirrored ? 'mirror' : ''}`}
|
||||||
@@ -135,7 +138,7 @@ export default function Clock(props: ClockProps) {
|
|||||||
data-testid='clock-view'
|
data-testid='clock-view'
|
||||||
>
|
>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={CLOCK_OPTIONS} />
|
<ViewParamsEditor paramFields={clockOptions} />
|
||||||
<SuperscriptTime
|
<SuperscriptTime
|
||||||
time={clock}
|
time={clock}
|
||||||
className='clock'
|
className='clock'
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent, ViewSettings } from 'ontime-types';
|
import { OntimeEvent, OntimeRundownEntry, Playback, Settings, SupportedEvent, ViewSettings } from 'ontime-types';
|
||||||
import { formatDisplay } from 'ontime-utils';
|
import { formatDisplay } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-editor/constants';
|
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
@@ -34,10 +34,11 @@ interface CountdownProps {
|
|||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Countdown(props: CountdownProps) {
|
export default function Countdown(props: CountdownProps) {
|
||||||
const { isMirrored, backstageEvents, time, selectedId, viewSettings } = props;
|
const { isMirrored, backstageEvents, time, selectedId, viewSettings, settings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
@@ -109,10 +110,12 @@ export default function Countdown(props: CountdownProps) {
|
|||||||
isSelected || runningMessage === TimerMessage.waiting,
|
isSelected || runningMessage === TimerMessage.waiting,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const timeOption = getTimeOption(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
<div className={`countdown ${isMirrored ? 'mirror' : ''}`} data-testid='countdown-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={[TIME_FORMAT_OPTION]} />
|
<ViewParamsEditor paramFields={[timeOption]} />
|
||||||
{follow === null ? (
|
{follow === null ? (
|
||||||
<CountdownSelect events={backstageEvents} />
|
<CountdownSelect events={backstageEvents} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import QRCode from 'react-qr-code';
|
import QRCode from 'react-qr-code';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Message, OntimeEvent, ProjectData, ViewSettings } from 'ontime-types';
|
import { Message, OntimeEvent, ProjectData, Settings, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
@@ -9,7 +9,7 @@ import Schedule from '../../../common/components/schedule/Schedule';
|
|||||||
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
import { ScheduleProvider } from '../../../common/components/schedule/ScheduleContext';
|
||||||
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
import ScheduleNav from '../../../common/components/schedule/ScheduleNav';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { PUBLIC_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { getPublicOptions } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
@@ -35,11 +35,22 @@ interface BackstageProps {
|
|||||||
publicSelectedId: string | null;
|
publicSelectedId: string | null;
|
||||||
general: ProjectData;
|
general: ProjectData;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Public(props: BackstageProps) {
|
export default function Public(props: BackstageProps) {
|
||||||
const { isMirrored, publ, publicEventNow, publicEventNext, time, events, publicSelectedId, general, viewSettings } =
|
const {
|
||||||
props;
|
isMirrored,
|
||||||
|
publ,
|
||||||
|
publicEventNow,
|
||||||
|
publicEventNext,
|
||||||
|
time,
|
||||||
|
events,
|
||||||
|
publicSelectedId,
|
||||||
|
general,
|
||||||
|
viewSettings,
|
||||||
|
settings,
|
||||||
|
} = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
|
|
||||||
@@ -56,10 +67,12 @@ export default function Public(props: BackstageProps) {
|
|||||||
const clock = formatTime(time.clock, formatOptions);
|
const clock = formatTime(time.clock, formatOptions);
|
||||||
const qrSize = Math.max(window.innerWidth / 15, 128);
|
const qrSize = Math.max(window.innerWidth / 15, 128);
|
||||||
|
|
||||||
|
const publicOptions = getPublicOptions(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
<div className={`public-screen ${isMirrored ? 'mirror' : ''}`} data-testid='public-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={PUBLIC_OPTIONS} />
|
<ViewParamsEditor paramFields={publicOptions} />
|
||||||
<div className='project-header'>
|
<div className='project-header'>
|
||||||
{general.title}
|
{general.title}
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ $half-hours: min(1.5vh, 10px);
|
|||||||
$size-min: min(2.5vh, 18px);
|
$size-min: min(2.5vh, 18px);
|
||||||
$half-min: min(1.25vh, 9px);
|
$half-min: min(1.25vh, 9px);
|
||||||
$red-active: #c53030;
|
$red-active: #c53030;
|
||||||
$red-idle: #000000;
|
$red-idle: #300000;
|
||||||
$cyan-active: #0ff;
|
$cyan-active: #0ff;
|
||||||
$cyan-idle: #0aa;
|
$cyan-idle: #0aa;
|
||||||
|
|
||||||
@@ -109,7 +109,8 @@ $cyan-idle: #0aa;
|
|||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
|
|
||||||
&--overtime {
|
&--overtime {
|
||||||
color: darken($red-active, 10%);
|
color: var(--studio-active, $red-active);
|
||||||
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ $cyan-idle: #0aa;
|
|||||||
color: var(--studio-active, $red-active);
|
color: var(--studio-active, $red-active);
|
||||||
|
|
||||||
&--idle {
|
&--idle {
|
||||||
color: var(--studio-idle, $red-active);
|
color: var(--studio-idle, $red-idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
|
import type { OntimeEvent, OntimeRundown, Settings, ViewSettings } from 'ontime-types';
|
||||||
import { SupportedEvent } from 'ontime-types';
|
import { SupportedEvent } from 'ontime-types';
|
||||||
import { formatDisplay } from 'ontime-utils';
|
import { formatDisplay } from 'ontime-utils';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
import { STUDIO_CLOCK_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { getStudioClockOptions } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import useFitText from '../../../common/hooks/useFitText';
|
import useFitText from '../../../common/hooks/useFitText';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
@@ -32,10 +32,11 @@ interface StudioClockProps {
|
|||||||
nextId: string | null;
|
nextId: string | null;
|
||||||
onAir: boolean;
|
onAir: boolean;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StudioClock(props: StudioClockProps) {
|
export default function StudioClock(props: StudioClockProps) {
|
||||||
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings } = props;
|
const { isMirrored, eventNext, time, backstageEvents, selectedId, nextId, onAir, viewSettings, settings } = props;
|
||||||
|
|
||||||
// deferring rendering seems to affect styling (font and useFitText)
|
// deferring rendering seems to affect styling (font and useFitText)
|
||||||
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
@@ -75,10 +76,12 @@ export default function StudioClock(props: StudioClockProps) {
|
|||||||
const secondsNow = secondsInMillis(time.clock);
|
const secondsNow = secondsInMillis(time.clock);
|
||||||
const isNegative = (time.current ?? 0) < 0;
|
const isNegative = (time.current ?? 0) < 0;
|
||||||
|
|
||||||
|
const studioClockOptions = getStudioClockOptions(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
<div className={`studio-clock ${isMirrored ? 'mirror' : ''}`} data-testid='studio-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={STUDIO_CLOCK_OPTIONS} />
|
<ViewParamsEditor paramFields={studioClockOptions} />
|
||||||
<div className='clock-container'>
|
<div className='clock-container'>
|
||||||
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
<div className={`studio-timer ${showSeconds ? 'studio-timer--with-seconds' : ''}`}>{clock}</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Message, OntimeEvent, Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
import { Message, OntimeEvent, Playback, Settings, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
|
||||||
|
|
||||||
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
import { overrideStylesURL } from '../../../common/api/apiConstants';
|
||||||
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
import MultiPartProgressBar from '../../../common/components/multi-part-progress-bar/MultiPartProgressBar';
|
||||||
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
import NavigationMenu from '../../../common/components/navigation-menu/NavigationMenu';
|
||||||
import TitleCard from '../../../common/components/title-card/TitleCard';
|
import TitleCard from '../../../common/components/title-card/TitleCard';
|
||||||
import { TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
|
import { getTimerOptions } from '../../../common/components/view-params-editor/constants';
|
||||||
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
|
||||||
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
|
||||||
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
import { TimeManagerType } from '../../../common/models/TimeManager.type';
|
||||||
@@ -48,10 +48,11 @@ interface TimerProps {
|
|||||||
eventNext: OntimeEvent | null;
|
eventNext: OntimeEvent | null;
|
||||||
time: TimeManagerType;
|
time: TimeManagerType;
|
||||||
viewSettings: ViewSettings;
|
viewSettings: ViewSettings;
|
||||||
|
settings: Settings | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Timer(props: TimerProps) {
|
export default function Timer(props: TimerProps) {
|
||||||
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external } = props;
|
const { isMirrored, pres, eventNow, eventNext, time, viewSettings, external, settings } = props;
|
||||||
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
|
||||||
const { getLocalizedString } = useTranslation();
|
const { getLocalizedString } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -128,10 +129,12 @@ export default function Timer(props: TimerProps) {
|
|||||||
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
|
const timerContainerClasses = `timer-container ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`;
|
||||||
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
|
||||||
|
|
||||||
|
const timerOptions = getTimerOptions(settings?.timeFormat ?? '24');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
<div className={showFinished ? `${baseClasses} stage-timer--finished` : baseClasses} data-testid='timer-view'>
|
||||||
<NavigationMenu />
|
<NavigationMenu />
|
||||||
<ViewParamsEditor paramFields={TIMER_OPTIONS} />
|
<ViewParamsEditor paramFields={timerOptions} />
|
||||||
{!userOptions.hideMessage && (
|
{!userOptions.hideMessage && (
|
||||||
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
|
||||||
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
|
||||||
|
|||||||
@@ -12,13 +12,38 @@ import './index.scss';
|
|||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
const root = createRoot(container as Element);
|
const root = createRoot(container as Element);
|
||||||
|
|
||||||
|
// https://docs.sentry.io/platforms/javascript/configuration/filtering/#decluttering-sentry
|
||||||
|
const sentryRecommendedIgnore = [
|
||||||
|
// Random plugins/extensions
|
||||||
|
'top.GLOBALS',
|
||||||
|
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
|
||||||
|
'originalCreateNotification',
|
||||||
|
'canvas.contentDocument',
|
||||||
|
'MyApp_RemoveAllHighlights',
|
||||||
|
'http://tt.epicplay.com',
|
||||||
|
"Can't find variable: ZiteReader",
|
||||||
|
'jigsaw is not defined',
|
||||||
|
'ComboSearch is not defined',
|
||||||
|
'http://loading.retry.widdit.com/',
|
||||||
|
'atomicFindClose',
|
||||||
|
// Facebook borked
|
||||||
|
'fb_xd_fragment',
|
||||||
|
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
|
||||||
|
// reduce this. (thanks @acdha)
|
||||||
|
// See http://stackoverflow.com/questions/4113268
|
||||||
|
'bmi_SafeAddOnload',
|
||||||
|
'EBCallBackMessageReceived',
|
||||||
|
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
|
||||||
|
'conduitPage',
|
||||||
|
];
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
|
dsn: 'https://5e4d2c4b57ab409cb98d4c08b2014755@o4504288369836032.ingest.sentry.io/4504288371343360',
|
||||||
integrations: [new BrowserTracing()],
|
integrations: [new BrowserTracing()],
|
||||||
tracesSampleRate: 0.3,
|
tracesSampleRate: 0.3,
|
||||||
release: ONTIME_VERSION,
|
release: ONTIME_VERSION,
|
||||||
enabled: import.meta.env.PROD,
|
enabled: import.meta.env.PROD,
|
||||||
ignoreErrors: ['top.GLOBALS', 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
|
ignoreErrors: [...sentryRecommendedIgnore, 'Unable to preload CSS', 'Failed to fetch dynamically imported module'],
|
||||||
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
denyUrls: [/extensions\//i, /^chrome:\/\//i, /^chrome-extension:\/\//i],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { langDe } from './languages/de';
|
|||||||
import { langEn } from './languages/en';
|
import { langEn } from './languages/en';
|
||||||
import { langEs } from './languages/es';
|
import { langEs } from './languages/es';
|
||||||
import { langFr } from './languages/fr';
|
import { langFr } from './languages/fr';
|
||||||
|
import { langIt } from './languages/it';
|
||||||
import { langNo } from './languages/no';
|
import { langNo } from './languages/no';
|
||||||
import { langPt } from './languages/pt';
|
import { langPt } from './languages/pt';
|
||||||
import { langSv } from './languages/sv';
|
import { langSv } from './languages/sv';
|
||||||
@@ -14,6 +15,7 @@ const translationsList = {
|
|||||||
en: langEn,
|
en: langEn,
|
||||||
es: langEs,
|
es: langEs,
|
||||||
fr: langFr,
|
fr: langFr,
|
||||||
|
it: langIt,
|
||||||
de: langDe,
|
de: langDe,
|
||||||
no: langNo,
|
no: langNo,
|
||||||
pt: langPt,
|
pt: langPt,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { TranslationObject } from './en';
|
||||||
|
|
||||||
|
export const langIt: TranslationObject = {
|
||||||
|
'common.end_time': 'Ora di Fine',
|
||||||
|
'common.expected_finish': 'Fine Prevista',
|
||||||
|
'common.now': 'Adesso',
|
||||||
|
'common.next': 'Prossimo',
|
||||||
|
'common.public_message': 'Messaggio pubblico',
|
||||||
|
'common.start_time': 'Ora di Inizio',
|
||||||
|
'common.stage_timer': 'Orologio Palco',
|
||||||
|
'common.started_at': 'Iniziato Alle',
|
||||||
|
'common.time_now': 'Ora attuale',
|
||||||
|
'countdown.ended': 'Evento finito alle',
|
||||||
|
'countdown.running': 'Evento in corso',
|
||||||
|
'countdown.select_event': 'Seleziona un evento da seguire',
|
||||||
|
'countdown.to_start': 'Tempo alla partenza',
|
||||||
|
'countdown.waiting': 'In attesa dell\'inizio dell\'evento',
|
||||||
|
'countdown.overtime': 'in ritardo',
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "2.21.3",
|
"version": "2.28.11",
|
||||||
"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",
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "2.21.3",
|
"version": "2.28.11",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@googleapis/sheets": "^5.0.5",
|
||||||
"body-parser": "^1.20.0",
|
"body-parser": "^1.20.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
@@ -12,6 +13,8 @@
|
|||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-static-gzip": "^2.1.7",
|
"express-static-gzip": "^2.1.7",
|
||||||
"express-validator": "^6.14.2",
|
"express-validator": "^6.14.2",
|
||||||
|
"got": "^14.0.0",
|
||||||
|
"google-auth-library": "^9.2.0",
|
||||||
"lowdb": "^5.0.5",
|
"lowdb": "^5.0.5",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-osc": "^9.0.2",
|
"node-osc": "^9.0.2",
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
"build:electron": "pnpm prebuild && esbuild src/app.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||||
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
"build:local": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --bundle --minify --outfile=dist/index.cjs",
|
||||||
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
|
"build:docker": "pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
|
||||||
|
"build:localdocker": "NODE_ENV=local pnpm prebuild && esbuild src/index.ts --log-level=error --platform=node --format=cjs --minify --bundle --outfile=dist/docker.cjs",
|
||||||
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
|
"build:debug": "pnpm prebuild && esbuild src/app.ts --platform=node --format=cjs --bundle --outfile=dist/index.cjs",
|
||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lint-staged": "eslint",
|
"lint-staged": "eslint",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { LogOrigin, OSCSettings } from 'ontime-types';
|
|||||||
import { Server } from 'node-osc';
|
import { Server } from 'node-osc';
|
||||||
|
|
||||||
import { IAdapter } from './IAdapter.js';
|
import { IAdapter } from './IAdapter.js';
|
||||||
import { dispatchFromAdapter } from '../controllers/integrationController.js';
|
import { dispatchFromAdapter, type ChangeOptions } from '../controllers/integrationController.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
|
|
||||||
export class OscServer implements IAdapter {
|
export class OscServer implements IAdapter {
|
||||||
@@ -36,12 +36,36 @@ export class OscServer implements IAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let transformedPayload: unknown = args;
|
||||||
|
// we need to transform the params for the change endpoint
|
||||||
|
// OSC: ontime/change/{eventID}/{propertyName} value
|
||||||
|
if (path === 'change') {
|
||||||
|
if (params.length < 2) {
|
||||||
|
logger.error(LogOrigin.Rx, 'OSC IN: No params provided for change');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args === undefined) {
|
||||||
|
logger.error(LogOrigin.Rx, 'OSC IN: No valid payload provided for change');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventId = params[0];
|
||||||
|
const property = params[1];
|
||||||
|
const value: string | number | boolean = args as string | number | boolean;
|
||||||
|
|
||||||
|
transformedPayload = {
|
||||||
|
eventId,
|
||||||
|
property,
|
||||||
|
value,
|
||||||
|
} satisfies ChangeOptions;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const reply = dispatchFromAdapter(
|
const reply = dispatchFromAdapter(
|
||||||
path,
|
path,
|
||||||
{
|
{
|
||||||
payload: args,
|
payload: transformedPayload,
|
||||||
params,
|
|
||||||
},
|
},
|
||||||
'osc',
|
'osc',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { LogOrigin } from 'ontime-types';
|
import { LogOrigin } from 'ontime-types';
|
||||||
|
|
||||||
import { WebSocket, WebSocketServer } from 'ws';
|
import { WebSocket, WebSocketServer } from 'ws';
|
||||||
|
import type { Server } from 'http';
|
||||||
|
|
||||||
import getRandomName from '../utils/getRandomName.js';
|
import getRandomName from '../utils/getRandomName.js';
|
||||||
import { IAdapter } from './IAdapter.js';
|
import { IAdapter } from './IAdapter.js';
|
||||||
@@ -43,7 +44,7 @@ export class SocketServer implements IAdapter {
|
|||||||
this.wss = null;
|
this.wss = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
init(server) {
|
init(server: Server) {
|
||||||
this.wss = new WebSocketServer({ path: '/ws', server });
|
this.wss = new WebSocketServer({ path: '/ws', server });
|
||||||
|
|
||||||
this.wss.on('connection', (ws) => {
|
this.wss.on('connection', (ws) => {
|
||||||
|
|||||||
+37
-14
@@ -1,14 +1,21 @@
|
|||||||
import { LogOrigin, OSCSettings } from 'ontime-types';
|
import { HttpSettings, LogOrigin, OSCSettings } from 'ontime-types';
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import expressStaticGzip from 'express-static-gzip';
|
import expressStaticGzip from 'express-static-gzip';
|
||||||
import http from 'http';
|
import http, { type Server } from 'http';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
|
||||||
// import utils
|
// import utils
|
||||||
import { join, resolve } from 'path';
|
import { join, resolve } from 'path';
|
||||||
import { currentDirectory, environment, externalsStartDirectory, isProduction, resolvedPath } from './setup.js';
|
import {
|
||||||
|
currentDirectory,
|
||||||
|
environment,
|
||||||
|
isProduction,
|
||||||
|
resolveExternalsDirectory,
|
||||||
|
resolveStylesDirectory,
|
||||||
|
resolvedPath,
|
||||||
|
} from './setup.js';
|
||||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||||
|
|
||||||
// Import Routes
|
// Import Routes
|
||||||
@@ -29,11 +36,13 @@ import { eventLoader } from './classes/event-loader/EventLoader.js';
|
|||||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||||
import { logger } from './classes/Logger.js';
|
import { logger } from './classes/Logger.js';
|
||||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||||
|
import { httpIntegration } from './services/integration-service/HttpIntegration.js';
|
||||||
import { populateStyles } from './modules/loadStyles.js';
|
import { populateStyles } from './modules/loadStyles.js';
|
||||||
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
import { eventStore, getInitialPayload } from './stores/EventStore.js';
|
||||||
import { PlaybackService } from './services/PlaybackService.js';
|
import { PlaybackService } from './services/PlaybackService.js';
|
||||||
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
import { RestorePoint, restoreService } from './services/RestoreService.js';
|
||||||
import { messageService } from './services/message-service/MessageService.js';
|
import { messageService } from './services/message-service/MessageService.js';
|
||||||
|
import { populateDemo } from './modules/loadDemo.js';
|
||||||
|
|
||||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||||
|
|
||||||
@@ -63,7 +72,11 @@ app.use('/ontime', ontimeRouter);
|
|||||||
app.use('/playback', playbackRouter);
|
app.use('/playback', playbackRouter);
|
||||||
|
|
||||||
// serve static - css
|
// serve static - css
|
||||||
app.use('/external', express.static(externalsStartDirectory));
|
app.use('/external/styles', express.static(resolveStylesDirectory));
|
||||||
|
app.use('/external/', express.static(resolveExternalsDirectory));
|
||||||
|
app.use('/external', (req, res) => {
|
||||||
|
res.status(404).send(`${req.originalUrl} not found`);
|
||||||
|
});
|
||||||
|
|
||||||
// serve static - react, in dev/test mode we fetch the React app from module
|
// serve static - react, in dev/test mode we fetch the React app from module
|
||||||
const reactAppPath = join(currentDirectory, resolvedPath());
|
const reactAppPath = join(currentDirectory, resolvedPath());
|
||||||
@@ -107,8 +120,8 @@ enum OntimeStartOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let step = OntimeStartOrder.InitAssets;
|
let step = OntimeStartOrder.InitAssets;
|
||||||
let expressServer = null;
|
let expressServer: Server | null = null;
|
||||||
let oscServer = null;
|
let oscServer: OscServer | null = null;
|
||||||
|
|
||||||
const checkStart = (currentState: OntimeStartOrder) => {
|
const checkStart = (currentState: OntimeStartOrder) => {
|
||||||
if (step !== currentState) {
|
if (step !== currentState) {
|
||||||
@@ -125,6 +138,7 @@ export const initAssets = async () => {
|
|||||||
checkStart(OntimeStartOrder.InitAssets);
|
checkStart(OntimeStartOrder.InitAssets);
|
||||||
await dbLoadingProcess;
|
await dbLoadingProcess;
|
||||||
populateStyles();
|
populateStyles();
|
||||||
|
populateDemo();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -165,7 +179,6 @@ export const startServer = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description starts OSC server
|
|
||||||
* @description starts OSC server
|
* @description starts OSC server
|
||||||
* @param overrideConfig
|
* @param overrideConfig
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
@@ -194,20 +207,30 @@ export const startOSCServer = async (overrideConfig = null) => {
|
|||||||
/**
|
/**
|
||||||
* starts integrations
|
* starts integrations
|
||||||
*/
|
*/
|
||||||
export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => {
|
||||||
checkStart(OntimeStartOrder.InitIO);
|
checkStart(OntimeStartOrder.InitIO);
|
||||||
|
|
||||||
const { osc } = config ?? DataProvider.getData();
|
const { osc, http } = config ?? DataProvider.getData();
|
||||||
|
|
||||||
if (!osc) {
|
if (!osc) {
|
||||||
return 'OSC Invalid configuration';
|
return 'OSC Invalid configuration';
|
||||||
|
} else {
|
||||||
|
const { success, message } = oscIntegration.init(osc);
|
||||||
|
logger.info(LogOrigin.Tx, message);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
integrationService.register(oscIntegration);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (!http) {
|
||||||
|
return 'HTTP Invalid configuration';
|
||||||
|
} else {
|
||||||
|
const { success, message } = httpIntegration.init(http);
|
||||||
|
logger.info(LogOrigin.Tx, message);
|
||||||
|
|
||||||
const { success, message } = oscIntegration.init(osc);
|
if (success) {
|
||||||
logger.info(LogOrigin.Tx, message);
|
integrationService.register(httpIntegration);
|
||||||
|
}
|
||||||
if (success) {
|
|
||||||
integrationService.register(oscIntegration);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export class DataProvider {
|
|||||||
return data.osc;
|
return data.osc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static getHttp() {
|
||||||
|
return data.http;
|
||||||
|
}
|
||||||
|
|
||||||
static getAliases() {
|
static getAliases() {
|
||||||
return data.aliases;
|
return data.aliases;
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,11 @@ export class DataProvider {
|
|||||||
await this.persist();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async setHttp(newData) {
|
||||||
|
data.http = { ...newData };
|
||||||
|
await this.persist();
|
||||||
|
}
|
||||||
|
|
||||||
static getRundown() {
|
static getRundown() {
|
||||||
return [...data.rundown];
|
return [...data.rundown];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ describe('safeMerge', () => {
|
|||||||
language: 'pt',
|
language: 'pt',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges the osc key', () => {
|
it('merges the osc key', () => {
|
||||||
const newData = {
|
const newData = {
|
||||||
osc: {
|
osc: {
|
||||||
|
|||||||
@@ -8,5 +8,9 @@ export const config = {
|
|||||||
directory: 'styles',
|
directory: 'styles',
|
||||||
filename: 'override.css',
|
filename: 'override.css',
|
||||||
},
|
},
|
||||||
|
demo: {
|
||||||
|
directory: 'demo',
|
||||||
|
filename: ['app.js', 'index.html', 'styles.css'],
|
||||||
|
},
|
||||||
restoreFile: 'ontime.restore',
|
restoreFile: 'ontime.restore',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { LogOrigin, OntimeEvent } from 'ontime-types';
|
import { LogOrigin, OntimeEvent } from 'ontime-types';
|
||||||
import { EventLoader } from '../classes/event-loader/EventLoader.js';
|
import { EventLoader } from '../classes/event-loader/EventLoader.js';
|
||||||
import { editEvent } from '../services/rundown-service/RundownService.js';
|
import { editEvent } from '../services/rundown-service/RundownService.js';
|
||||||
import { coerceString, coerceNumber, coerceBoolean } from '../utils/coerceType.js';
|
import { coerceString, coerceNumber, coerceBoolean, coerceColour } from '../utils/coerceType.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
|
import { isKeyOfType, isOntimeEvent } from 'ontime-types/src/utils/guards.js';
|
||||||
|
|
||||||
const whitelistedPayload = {
|
const whitelistedPayload = {
|
||||||
title: coerceString,
|
title: coerceString,
|
||||||
@@ -16,7 +17,8 @@ const whitelistedPayload = {
|
|||||||
isPublic: coerceBoolean,
|
isPublic: coerceBoolean,
|
||||||
skip: coerceBoolean,
|
skip: coerceBoolean,
|
||||||
|
|
||||||
colour: coerceString,
|
colour: coerceColour,
|
||||||
|
|
||||||
user0: coerceString,
|
user0: coerceString,
|
||||||
user1: coerceString,
|
user1: coerceString,
|
||||||
user2: coerceString,
|
user2: coerceString,
|
||||||
@@ -29,12 +31,12 @@ const whitelistedPayload = {
|
|||||||
user9: coerceString,
|
user9: coerceString,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function parse(field: string, value: unknown) {
|
export function parse(property: string, value: unknown) {
|
||||||
if (!Object.hasOwn(whitelistedPayload, field)) {
|
if (!isKeyOfType(property, whitelistedPayload)) {
|
||||||
throw new Error(`Field ${field} not permitted`);
|
throw new Error(`Property ${property} not permitted`);
|
||||||
}
|
}
|
||||||
const parserFn = whitelistedPayload[field];
|
const parserFn = whitelistedPayload[property];
|
||||||
return parserFn(value);
|
return { parsedProperty: property, parsedPayload: parserFn(value) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,8 +51,10 @@ export function updateEvent(
|
|||||||
newValue: OntimeEvent[typeof propertyName],
|
newValue: OntimeEvent[typeof propertyName],
|
||||||
) {
|
) {
|
||||||
const event = EventLoader.getEventWithId(eventId);
|
const event = EventLoader.getEventWithId(eventId);
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
|
if (!isOntimeEvent(event)) {
|
||||||
|
throw new Error(`Can only update events`);
|
||||||
|
}
|
||||||
const propertiesToUpdate = { [propertyName]: newValue };
|
const propertiesToUpdate = { [propertyName]: newValue };
|
||||||
|
|
||||||
// Handles the special case for duration
|
// Handles the special case for duration
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import { OntimeEvent } from 'ontime-types';
|
|
||||||
|
|
||||||
import { messageService } from '../services/message-service/MessageService.js';
|
import { messageService } from '../services/message-service/MessageService.js';
|
||||||
import { PlaybackService } from '../services/PlaybackService.js';
|
import { PlaybackService } from '../services/PlaybackService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import { parse, updateEvent } from './integrationController.config.js';
|
import { parse, updateEvent } from './integrationController.config.js';
|
||||||
import { isKeyOfType } from 'ontime-types/src/utils/guards.js';
|
|
||||||
import { event } from '../models/eventsDefinition.js';
|
export type ChangeOptions = {
|
||||||
|
eventId: string;
|
||||||
|
property: string;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
//TODO: re-throwing the error does not add any extra information or value
|
//TODO: re-throwing the error does not add any extra information or value
|
||||||
export function dispatchFromAdapter(
|
export function dispatchFromAdapter(
|
||||||
type: string,
|
type: string,
|
||||||
args: {
|
args: {
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
params?: Array<string>;
|
|
||||||
},
|
},
|
||||||
_source?: 'osc' | 'ws',
|
_source?: 'osc' | 'ws',
|
||||||
) {
|
) {
|
||||||
const payload = args.payload;
|
const payload = args.payload;
|
||||||
const typeComponents = type.toLowerCase().split('/');
|
const typeComponents = type.toLowerCase().split('/');
|
||||||
const mainType = typeComponents[0];
|
const mainType = typeComponents[0];
|
||||||
const params = args.params || [];
|
|
||||||
|
|
||||||
switch (mainType) {
|
switch (mainType) {
|
||||||
case 'test-ontime': {
|
case 'test-ontime': {
|
||||||
@@ -139,11 +139,10 @@ export function dispatchFromAdapter(
|
|||||||
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Indexes in frontend are 1 based
|
||||||
// Indexes in frontend are 1 based
|
const success = PlaybackService.startByIndex(eventIndex - 1);
|
||||||
PlaybackService.startByIndex(eventIndex - 1);
|
if (!success) {
|
||||||
} catch (error) {
|
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||||
throw new Error(`Error loading event:: ${error}`);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -194,11 +193,8 @@ export function dispatchFromAdapter(
|
|||||||
if (isNaN(time)) {
|
if (isNaN(time)) {
|
||||||
throw new Error(`Time not recognised ${payload}`);
|
throw new Error(`Time not recognised ${payload}`);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
PlaybackService.addTime(time);
|
PlaybackService.addTime(time);
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Could not add time: ${error}`);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
//deprecated
|
//deprecated
|
||||||
@@ -208,11 +204,7 @@ export function dispatchFromAdapter(
|
|||||||
throw new Error(`Delay time not recognised ${payload}`);
|
throw new Error(`Delay time not recognised ${payload}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
PlaybackService.setDelay(delayTime);
|
||||||
PlaybackService.setDelay(delayTime);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Could not add delay: ${error}`);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'gotoindex':
|
case 'gotoindex':
|
||||||
@@ -222,11 +214,10 @@ export function dispatchFromAdapter(
|
|||||||
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Indexes in frontend are 1 based
|
||||||
// Indexes in frontend are 1 based
|
const success = PlaybackService.loadByIndex(eventIndex - 1);
|
||||||
PlaybackService.loadByIndex(eventIndex - 1);
|
if (!success) {
|
||||||
} catch (error) {
|
throw new Error(`Event index not recognised or out of range ${eventIndex}`);
|
||||||
throw new Error(`Event index not recognised or out of range ${error}`);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -236,10 +227,9 @@ export function dispatchFromAdapter(
|
|||||||
throw new Error(`Event ID not recognised: ${payload}`);
|
throw new Error(`Event ID not recognised: ${payload}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const success = PlaybackService.loadById(payload.toString().toLowerCase());
|
||||||
PlaybackService.loadById(payload.toString().toLowerCase());
|
if (!success) {
|
||||||
} catch (error) {
|
throw new Error(`Event ID not found: ${payload}`);
|
||||||
throw new Error(`OSC IN: error calling goto ${error}`);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -249,10 +239,9 @@ export function dispatchFromAdapter(
|
|||||||
throw new Error(`Event cue not recognised: ${payload}`);
|
throw new Error(`Event cue not recognised: ${payload}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const success = PlaybackService.loadByCue(payload);
|
||||||
PlaybackService.loadByCue(payload);
|
if (!success) {
|
||||||
} catch (error) {
|
throw new Error(`Event cue not found: ${payload}`);
|
||||||
throw new Error(`OSC IN: error calling goto ${error}`);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -267,21 +256,11 @@ export function dispatchFromAdapter(
|
|||||||
return { topic: 'timer', payload: timer };
|
return { topic: 'timer', payload: timer };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ontime/change/{eventID}/{propertyName}
|
// WS: {type: 'change', payload: { eventId, property, value } }
|
||||||
case 'change': {
|
case 'change': {
|
||||||
if (params.length < 2) {
|
const { eventId, property, value } = payload as ChangeOptions;
|
||||||
throw new Error('Too few parameters, 3 expected');
|
const { parsedPayload, parsedProperty } = parse(property, value);
|
||||||
}
|
return updateEvent(eventId, parsedProperty, parsedPayload);
|
||||||
if (payload === undefined) {
|
|
||||||
throw new Error('No payload found');
|
|
||||||
}
|
|
||||||
const eventID = params[0];
|
|
||||||
const propertyName = params[1] as keyof OntimeEvent;
|
|
||||||
if (!isKeyOfType(propertyName, event)) {
|
|
||||||
throw new Error(`Cannot update unknown event property ${propertyName}`);
|
|
||||||
}
|
|
||||||
const parsedPayload = parse(propertyName, payload);
|
|
||||||
return updateEvent(eventID, propertyName, parsedPayload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Alias, DatabaseModel, GetInfo, LogOrigin, ProjectData } from 'ontime-types';
|
import { LogOrigin } from 'ontime-types';
|
||||||
|
import type { Alias, DatabaseModel, GetInfo, HttpSettings, ProjectData } from 'ontime-types';
|
||||||
|
|
||||||
import { RequestHandler, Request, Response } from 'express';
|
import { RequestHandler, Request, Response } from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -9,13 +10,17 @@ import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
|||||||
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js';
|
||||||
import { PlaybackService } from '../services/PlaybackService.js';
|
import { PlaybackService } from '../services/PlaybackService.js';
|
||||||
import { eventStore } from '../stores/EventStore.js';
|
import { eventStore } from '../stores/EventStore.js';
|
||||||
import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js';
|
import { isDocker, resolveDbPath, resolveStylesPath } from '../setup.js';
|
||||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||||
|
import { httpIntegration } from '../services/integration-service/HttpIntegration.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js';
|
||||||
import { deepmerge } from 'ontime-utils';
|
import { deepmerge } from 'ontime-utils';
|
||||||
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
import { runtimeCacheStore } from '../stores/cachingStore.js';
|
||||||
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
|
import { delayedRundownCacheKey } from '../services/rundown-service/delayedRundown.utils.js';
|
||||||
|
import { integrationService } from '../services/integration-service/IntegrationService.js';
|
||||||
|
|
||||||
|
import { sheet } from '../utils/sheetsAuth.js';
|
||||||
|
|
||||||
// Create controller for GET request to '/ontime/poll'
|
// Create controller for GET request to '/ontime/poll'
|
||||||
// Returns data for current state
|
// Returns data for current state
|
||||||
@@ -114,7 +119,7 @@ export const getInfo = async (req: Request, res: Response<GetInfo>) => {
|
|||||||
// get nif and inject localhost
|
// get nif and inject localhost
|
||||||
const ni = getNetworkInterfaces();
|
const ni = getNetworkInterfaces();
|
||||||
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
ni.unshift({ name: 'localhost', address: '127.0.0.1' });
|
||||||
const cssOverride = pathToStartStyles;
|
const cssOverride = resolveStylesPath;
|
||||||
|
|
||||||
// send object with network information
|
// send object with network information
|
||||||
res.status(200).send({
|
res.status(200).send({
|
||||||
@@ -283,15 +288,42 @@ export const getOSC = async (req, res) => {
|
|||||||
res.status(200).send(osc);
|
res.status(200).send(osc);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create controller for POST request to '/ontime/osc'
|
||||||
|
// Returns ACK message
|
||||||
|
export const postOSC = async (req, res) => {
|
||||||
|
if (failEmptyObjects(req.body, res)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oscSettings = req.body;
|
||||||
|
await DataProvider.setOsc(oscSettings);
|
||||||
|
|
||||||
|
integrationService.unregister(oscIntegration);
|
||||||
|
|
||||||
|
// TODO: this update could be more granular, checking that relevant data was changed
|
||||||
|
const { success, message } = oscIntegration.init(oscSettings);
|
||||||
|
logger.info(LogOrigin.Tx, message);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
integrationService.register(oscIntegration);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(oscSettings).status(200);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const postOscSubscriptions = async (req, res) => {
|
export const postOscSubscriptions = async (req, res) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
if (failEmptyObjects(req.body, res)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oscSubscriptions = req.body;
|
const subscriptions = req.body;
|
||||||
const oscSettings = DataProvider.getOsc();
|
const oscSettings = DataProvider.getOsc();
|
||||||
oscSettings.subscriptions = oscSubscriptions;
|
oscSettings.subscriptions = subscriptions;
|
||||||
await DataProvider.setOsc(oscSettings);
|
await DataProvider.setOsc(oscSettings);
|
||||||
|
|
||||||
// TODO: this update could be more granular, checking that relevant data was changed
|
// TODO: this update could be more granular, checking that relevant data was changed
|
||||||
@@ -304,22 +336,33 @@ export const postOscSubscriptions = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create controller for POST request to '/ontime/osc'
|
// Create controller for GET request to '/ontime/http'
|
||||||
// Returns ACK message
|
export const getHTTP = async (_req, res: Response<HttpSettings>) => {
|
||||||
export const postOSC = async (req, res) => {
|
const http = DataProvider.getHttp();
|
||||||
|
res.status(200).send(http);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create controller for POST request to '/ontime/http'
|
||||||
|
export const postHTTP = async (req, res) => {
|
||||||
if (failEmptyObjects(req.body, res)) {
|
if (failEmptyObjects(req.body, res)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oscSettings = req.body;
|
const httpSettings = req.body;
|
||||||
await DataProvider.setOsc(oscSettings);
|
await DataProvider.setHttp(httpSettings);
|
||||||
|
|
||||||
|
integrationService.unregister(httpIntegration);
|
||||||
|
|
||||||
// TODO: this update could be more granular, checking that relevant data was changed
|
// TODO: this update could be more granular, checking that relevant data was changed
|
||||||
const { message } = oscIntegration.init(oscSettings);
|
const { success, message } = httpIntegration.init(httpSettings);
|
||||||
logger.info(LogOrigin.Tx, message);
|
logger.info(LogOrigin.Tx, message);
|
||||||
|
|
||||||
res.send(oscSettings).status(200);
|
if (success) {
|
||||||
|
integrationService.register(httpIntegration);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(httpSettings).status(200);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).send({ message: error.toString() });
|
res.status(400).send({ message: error.toString() });
|
||||||
}
|
}
|
||||||
@@ -414,3 +457,122 @@ export const postNew: RequestHandler = async (req, res) => {
|
|||||||
res.status(400).send({ message: error.toString() });
|
res.status(400).send({ message: error.toString() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
//SHEET Functions
|
||||||
|
/**
|
||||||
|
* @description SETP-1 POST Client Secrect
|
||||||
|
* @returns parsed result
|
||||||
|
*/
|
||||||
|
export async function uploadSheetClientFile(req, res) {
|
||||||
|
if (!req.file.path) {
|
||||||
|
res.status(400).send({ message: 'File not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const client = JSON.parse(fs.readFileSync(req.file.path as string, 'utf-8'));
|
||||||
|
await sheet.saveClientSecrets(client);
|
||||||
|
res.status(200).send('OK');
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
fs.unlink(req.file.path, (err) => {
|
||||||
|
if (err) logger.error(LogOrigin.Server, err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP-1 GET Client Secrect status
|
||||||
|
*/
|
||||||
|
export const getClientSecrect = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const clientSecrectExists = await sheet.testClientSecret();
|
||||||
|
if (clientSecrectExists) {
|
||||||
|
res.status(200).send();
|
||||||
|
} else {
|
||||||
|
res.status(500).send({ message: 'The Client ID does not exist' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP-2 GET sheet authentication url
|
||||||
|
*/
|
||||||
|
export async function getAuthenticationUrl(req, res) {
|
||||||
|
try {
|
||||||
|
const authUrl = await sheet.openAuthServer();
|
||||||
|
res.status(200).send(authUrl);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP-2 GET sheet authentication status
|
||||||
|
*/
|
||||||
|
export const getAuthentication = async (req, res) => {
|
||||||
|
try {
|
||||||
|
await sheet.testAuthentication();
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP-3 POST sheet id
|
||||||
|
* @returns list of worksheets
|
||||||
|
*/
|
||||||
|
export const postId = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.body;
|
||||||
|
if (id.lenght < 40) {
|
||||||
|
res.status(400).send({ message: 'ID is usualy 44 characters long' });
|
||||||
|
}
|
||||||
|
const state = await sheet.testSheetId(id);
|
||||||
|
res.status(200).send(state);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP-4 POST worksheet
|
||||||
|
*/
|
||||||
|
export const postWorksheet = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { worksheet, id } = req.body;
|
||||||
|
const state = await sheet.testWorksheet(worksheet, id);
|
||||||
|
res.status(200).send(state);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP-5 POST download undown to sheet
|
||||||
|
* @returns parsed result
|
||||||
|
*/
|
||||||
|
export async function pullSheet(req, res) {
|
||||||
|
try {
|
||||||
|
const { id, options } = req.body;
|
||||||
|
const data = await sheet.pull(id, options);
|
||||||
|
res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP-5 POST upload rundown to sheet
|
||||||
|
*/
|
||||||
|
export async function pushSheet(req, res) {
|
||||||
|
try {
|
||||||
|
const { id, options } = req.body;
|
||||||
|
await sheet.push(id, options);
|
||||||
|
res.status(200).send();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send({ message: error.toString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { body, check, validationResult } from 'express-validator';
|
import { body, check, validationResult } from 'express-validator';
|
||||||
import { validateOscObject, validateOscSubscriptionEntry } from '../utils/parserFunctions.js';
|
import {
|
||||||
|
validateHttpSubscriptionObject,
|
||||||
|
validateOscSubscriptionObject,
|
||||||
|
validateOscSubscriptionCycle,
|
||||||
|
} from '../utils/parserFunctions.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Validates object for POST /ontime/views
|
* @description Validates object for POST /ontime/views
|
||||||
@@ -82,7 +86,22 @@ export const validateOSC = [
|
|||||||
body('enabledOut').exists().isBoolean(),
|
body('enabledOut').exists().isBoolean(),
|
||||||
body('subscriptions')
|
body('subscriptions')
|
||||||
.isObject()
|
.isObject()
|
||||||
.custom((value) => validateOscObject(value)),
|
.custom((value) => validateOscSubscriptionObject(value)),
|
||||||
|
(req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Validates object for POST /ontime/http
|
||||||
|
*/
|
||||||
|
export const validateHTTP = [
|
||||||
|
body('enabledOut').exists().isBoolean(),
|
||||||
|
body('subscriptions')
|
||||||
|
.isObject()
|
||||||
|
.custom((value) => validateHttpSubscriptionObject(value)),
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
@@ -96,22 +115,22 @@ export const validateOSC = [
|
|||||||
export const validateOscSubscription = [
|
export const validateOscSubscription = [
|
||||||
body('onLoad')
|
body('onLoad')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
body('onStart')
|
body('onStart')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
body('onPause')
|
body('onPause')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
body('onStop')
|
body('onStop')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
body('onUpdate')
|
body('onUpdate')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
body('onFinish')
|
body('onFinish')
|
||||||
.isArray()
|
.isArray()
|
||||||
.custom((value) => validateOscSubscriptionEntry(value)),
|
.custom((value) => validateOscSubscriptionCycle(value)),
|
||||||
(req, res, next) => {
|
(req, res, next) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
@@ -133,3 +152,32 @@ export const validatePatchProjectFile = [
|
|||||||
next();
|
next();
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const validateSheetid = [
|
||||||
|
body('id').exists().isString(),
|
||||||
|
(req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const validateWorksheet = [
|
||||||
|
body('id').exists().isString(),
|
||||||
|
body('worksheet').exists().isString(),
|
||||||
|
(req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const validateSheetOptions = [
|
||||||
|
body('id').exists().isString(),
|
||||||
|
// body('options').exists().isObject(), TODO:
|
||||||
|
(req, res, next) => {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
Vendored
+67
@@ -0,0 +1,67 @@
|
|||||||
|
/*eslint-env browser*/
|
||||||
|
/**
|
||||||
|
* This is a very minimal example for a websocket client
|
||||||
|
* You could use this as a starting point to creating your own interfaces
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mts = 1000; // millis to seconds
|
||||||
|
const mtm = 1000 * 60; // millis to minutes
|
||||||
|
const mth = 1000 * 60 * 60; // millis to hours
|
||||||
|
|
||||||
|
const leftPad = (number) => {
|
||||||
|
return Math.floor(number).toString().padStart(2, '0');
|
||||||
|
};
|
||||||
|
|
||||||
|
let reconnectTimeout;
|
||||||
|
const reconnectInterval = 1000;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
|
||||||
|
const connectSocket = () => {
|
||||||
|
const websocket = new WebSocket(`ws://${window.location.hostname}:${window.location.port}/ws`);
|
||||||
|
|
||||||
|
websocket.onopen = () => {
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
console.info('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onclose = () => {
|
||||||
|
console.warn('WebSocket disconnected');
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
console.warn(`WebSocket: attempting reconnect ${reconnectAttempts}`);
|
||||||
|
if (websocket && websocket.readyState === WebSocket.CLOSED) {
|
||||||
|
reconnectAttempts += 1;
|
||||||
|
connectSocket();
|
||||||
|
}
|
||||||
|
}, reconnectInterval);
|
||||||
|
};
|
||||||
|
websocket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// all objects from ontime are structured with type and payload
|
||||||
|
const { type, payload } = data;
|
||||||
|
|
||||||
|
// we only need to read message type of ontime
|
||||||
|
if (type === 'ontime') {
|
||||||
|
// destructure known data from ontime
|
||||||
|
// see https://cpvalente.gitbook.io/ontime/control-and-feedback/websocket-api
|
||||||
|
const { timer, playback } = payload;
|
||||||
|
const timerElement = document.getElementById('timer');
|
||||||
|
if (playback == 'stop') {
|
||||||
|
timerElement.innerText = '--:--:--';
|
||||||
|
} else {
|
||||||
|
const millis = Math.abs(timer.current);
|
||||||
|
const isNegative = timer.current < 0;
|
||||||
|
timerElement.innerText = `${isNegative ? '-' : ''}${leftPad(millis / mth)}:${leftPad(
|
||||||
|
(millis % mth) / mtm,
|
||||||
|
)}:${leftPad((millis % mtm) / mts)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
connectSocket();
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>ontime demo</title>
|
||||||
|
<link href="./styles.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="timer"></div>
|
||||||
|
<script src="./app.js" type="module"></script>
|
||||||
|
</html>
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
color: azure;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 20vw;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
@@ -57,4 +57,15 @@ export const dbModel: DatabaseModel = {
|
|||||||
onFinish: [],
|
onFinish: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
http: {
|
||||||
|
enabledOut: false,
|
||||||
|
subscriptions: {
|
||||||
|
onLoad: [],
|
||||||
|
onStart: [],
|
||||||
|
onPause: [],
|
||||||
|
onStop: [],
|
||||||
|
onUpdate: [],
|
||||||
|
onFinish: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { copyFile } from 'fs/promises';
|
||||||
|
import { pathToStartDemo, resolveDemoDirectory, resolveDemoPath } from '../setup.js';
|
||||||
|
import { ensureDirectory } from '../utils/fileManagement.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description ensures directories exist and populates demo folder
|
||||||
|
*/
|
||||||
|
export const populateDemo = () => {
|
||||||
|
ensureDirectory(resolveDemoDirectory);
|
||||||
|
// even if demo exist we want to use startup demo
|
||||||
|
try {
|
||||||
|
Promise.all(
|
||||||
|
resolveDemoPath.map((to, index) => {
|
||||||
|
const from = pathToStartDemo[index];
|
||||||
|
copyFile(from, to);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
/* we do not handle this */
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,20 +4,15 @@ import { ensureDirectory } from '../utils/fileManagement.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @description ensures directories exist and populates stylesheet
|
* @description ensures directories exist and populates stylesheet
|
||||||
* @return {string} - path to stylesheet file
|
|
||||||
*/
|
*/
|
||||||
export const populateStyles = () => {
|
export const populateStyles = () => {
|
||||||
const stylesInDisk = resolveStylesPath;
|
|
||||||
ensureDirectory(resolveStylesDirectory);
|
ensureDirectory(resolveStylesDirectory);
|
||||||
|
// if styles doesn't exist we want to use startup stylesheet
|
||||||
// if stylesInDisk doesn't exist we want to use startup stylesheet
|
if (!existsSync(resolveStylesPath)) {
|
||||||
if (!existsSync(stylesInDisk)) {
|
|
||||||
try {
|
try {
|
||||||
copyFileSync(pathToStartStyles, stylesInDisk);
|
copyFileSync(pathToStartStyles, resolveStylesPath);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* we do not handle this */
|
/* we do not handle this */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stylesInDisk;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
getAliases,
|
getAliases,
|
||||||
getInfo,
|
getInfo,
|
||||||
getOSC,
|
getOSC,
|
||||||
|
getHTTP,
|
||||||
getSettings,
|
getSettings,
|
||||||
getUserFields,
|
getUserFields,
|
||||||
getViewSettings,
|
getViewSettings,
|
||||||
@@ -19,16 +20,28 @@ import {
|
|||||||
postUserFields,
|
postUserFields,
|
||||||
postViewSettings,
|
postViewSettings,
|
||||||
previewExcel,
|
previewExcel,
|
||||||
|
postHTTP,
|
||||||
|
getAuthenticationUrl,
|
||||||
|
uploadSheetClientFile as uploadClientSecret,
|
||||||
|
pullSheet,
|
||||||
|
pushSheet,
|
||||||
|
postId,
|
||||||
|
getAuthentication,
|
||||||
|
getClientSecrect as getClientSecret,
|
||||||
} from '../controllers/ontimeController.js';
|
} from '../controllers/ontimeController.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateAliases,
|
validateAliases,
|
||||||
validateOSC,
|
validateOSC,
|
||||||
validateOscSubscription,
|
|
||||||
validatePatchProjectFile,
|
validatePatchProjectFile,
|
||||||
validateSettings,
|
validateSettings,
|
||||||
validateUserFields,
|
validateUserFields,
|
||||||
viewValidator,
|
viewValidator,
|
||||||
|
validateHTTP,
|
||||||
|
validateOscSubscription,
|
||||||
|
validateSheetid,
|
||||||
|
validateWorksheet,
|
||||||
|
validateSheetOptions,
|
||||||
} from '../controllers/ontimeController.validate.js';
|
} from '../controllers/ontimeController.validate.js';
|
||||||
import { projectSanitiser } from '../controllers/projectController.validate.js';
|
import { projectSanitiser } from '../controllers/projectController.validate.js';
|
||||||
|
|
||||||
@@ -85,5 +98,31 @@ router.post('/osc', validateOSC, postOSC);
|
|||||||
// create route between controller and '/ontime/osc-subscriptions' endpoint
|
// create route between controller and '/ontime/osc-subscriptions' endpoint
|
||||||
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
|
router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions);
|
||||||
|
|
||||||
|
// create route between controller and '/ontime/http' endpoint
|
||||||
|
router.get('/http', getHTTP);
|
||||||
|
|
||||||
|
// create route between controller and '/ontime/http' endpoint
|
||||||
|
router.post('/http', validateHTTP, postHTTP);
|
||||||
|
|
||||||
// create route between controller and '/ontime/new' endpoint
|
// create route between controller and '/ontime/new' endpoint
|
||||||
router.post('/new', projectSanitiser, postNew);
|
router.post('/new', projectSanitiser, postNew);
|
||||||
|
|
||||||
|
//SETP-1
|
||||||
|
router.post('/sheet/clientsecret', uploadFile, uploadClientSecret);
|
||||||
|
router.get('/sheet/clientsecret', uploadFile, getClientSecret);
|
||||||
|
|
||||||
|
//SETP-2
|
||||||
|
router.get('/sheet/authentication/url', getAuthenticationUrl);
|
||||||
|
router.get('/sheet/authentication', getAuthentication);
|
||||||
|
|
||||||
|
//STEP-3
|
||||||
|
router.post('/sheet/id', validateSheetid, postId);
|
||||||
|
|
||||||
|
//STEP-4
|
||||||
|
router.post('/sheet/worksheet', validateWorksheet, postId);
|
||||||
|
|
||||||
|
//STEP-5 download and generate preview
|
||||||
|
router.post('/sheet/pull', validateSheetOptions, pullSheet);
|
||||||
|
|
||||||
|
//STEP-5 upload
|
||||||
|
router.post('/sheet-push', validateSheetOptions, pushSheet);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { eventStore } from '../stores/EventStore.js';
|
|||||||
import { PlaybackService } from './PlaybackService.js';
|
import { PlaybackService } from './PlaybackService.js';
|
||||||
import { updateRoll } from './rollUtils.js';
|
import { updateRoll } from './rollUtils.js';
|
||||||
import { integrationService } from './integration-service/IntegrationService.js';
|
import { integrationService } from './integration-service/IntegrationService.js';
|
||||||
import { getCurrent, getExpectedFinish } from './timerUtils.js';
|
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from './timerUtils.js';
|
||||||
import { clock } from './Clock.js';
|
import { clock } from './Clock.js';
|
||||||
import { logger } from '../classes/Logger.js';
|
import { logger } from '../classes/Logger.js';
|
||||||
import type { RestorePoint } from './RestoreService.js';
|
import type { RestorePoint } from './RestoreService.js';
|
||||||
@@ -18,10 +18,13 @@ type initialLoadingData = {
|
|||||||
|
|
||||||
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
|
type RestoreCallback = (newState: RestorePoint) => Promise<void>;
|
||||||
|
|
||||||
|
export const timeSkipLimit = 3 * 32;
|
||||||
|
|
||||||
export class TimerService {
|
export class TimerService {
|
||||||
private readonly _interval: NodeJS.Timer;
|
private readonly _interval: NodeJS.Timer;
|
||||||
private _updateInterval: number;
|
private _updateInterval: number;
|
||||||
private _lastUpdate: number | null;
|
private _lastUpdate: number | null;
|
||||||
|
private _skipThreshold: number;
|
||||||
|
|
||||||
playback: Playback;
|
playback: Playback;
|
||||||
timer: TimerState;
|
timer: TimerState;
|
||||||
@@ -40,11 +43,13 @@ export class TimerService {
|
|||||||
* @param {object} [timerConfig]
|
* @param {object} [timerConfig]
|
||||||
* @param {number} [timerConfig.refresh]
|
* @param {number} [timerConfig.refresh]
|
||||||
* @param {number} [timerConfig.updateInterval]
|
* @param {number} [timerConfig.updateInterval]
|
||||||
|
* @param {number} [timerConfig.skipThreshold]
|
||||||
*/
|
*/
|
||||||
constructor(timerConfig: { refresh?: number; updateInterval?: number } = {}) {
|
constructor(timerConfig: { refresh: number; updateInterval: number; skipThreshold: number }) {
|
||||||
this._clear();
|
this._clear();
|
||||||
this._interval = setInterval(() => this.update(), timerConfig?.refresh ?? 1000);
|
this._interval = setInterval(() => this.update(), timerConfig.refresh);
|
||||||
this._updateInterval = timerConfig?.updateInterval ?? 1000;
|
this._updateInterval = timerConfig.updateInterval;
|
||||||
|
this._skipThreshold = timerConfig.skipThreshold;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -339,7 +344,6 @@ export class TimerService {
|
|||||||
this.timer.expectedFinish >= this.timer.startedAt
|
this.timer.expectedFinish >= this.timer.startedAt
|
||||||
? this.timer.expectedFinish
|
? this.timer.expectedFinish
|
||||||
: this.timer.expectedFinish + dayInMs,
|
: this.timer.expectedFinish + dayInMs,
|
||||||
|
|
||||||
clock: this.timer.clock,
|
clock: this.timer.clock,
|
||||||
secondaryTimer: this.timer.secondaryTimer,
|
secondaryTimer: this.timer.secondaryTimer,
|
||||||
secondaryTarget: this.secondaryTarget,
|
secondaryTarget: this.secondaryTarget,
|
||||||
@@ -405,7 +409,19 @@ export class TimerService {
|
|||||||
let shouldNotify = false;
|
let shouldNotify = false;
|
||||||
if (this.playback === Playback.Roll) {
|
if (this.playback === Playback.Roll) {
|
||||||
shouldNotify = true;
|
shouldNotify = true;
|
||||||
this.updateRoll();
|
if (
|
||||||
|
skippedOutOfEvent(
|
||||||
|
previousTime,
|
||||||
|
this.timer.clock,
|
||||||
|
this.timer.startedAt,
|
||||||
|
this.timer.expectedFinish,
|
||||||
|
this._skipThreshold,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
PlaybackService.roll();
|
||||||
|
} else {
|
||||||
|
this.updateRoll();
|
||||||
|
}
|
||||||
} else if (this.timer.startedAt !== null) {
|
} else if (this.timer.startedAt !== null) {
|
||||||
// we only update timer if a timer has been started
|
// we only update timer if a timer has been started
|
||||||
shouldNotify = true;
|
shouldNotify = true;
|
||||||
@@ -505,4 +521,5 @@ export class TimerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calculate at 30fps, refresh at 1fps
|
// calculate at 30fps, refresh at 1fps
|
||||||
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000 });
|
// we consider a skip at 3 lost updates
|
||||||
|
export const eventTimer = new TimerService({ refresh: 32, updateInterval: 1000, skipThreshold: 32 * 3 });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
import { TimerType } from 'ontime-types';
|
import { TimerType } from 'ontime-types';
|
||||||
|
|
||||||
import { getCurrent, getExpectedFinish } from '../timerUtils.js';
|
import { getCurrent, getExpectedFinish, skippedOutOfEvent } from '../timerUtils.js';
|
||||||
|
|
||||||
describe('getExpectedFinish()', () => {
|
describe('getExpectedFinish()', () => {
|
||||||
it('is null if we havent started', () => {
|
it('is null if we havent started', () => {
|
||||||
@@ -354,3 +354,106 @@ describe('getExpectedFinish() and getCurrentTime() combined', () => {
|
|||||||
expect(current).toBe(8);
|
expect(current).toBe(8);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('skippedOutOfEvent()', () => {
|
||||||
|
const testSkipLimit = 32;
|
||||||
|
it('does not consider an event end as a skip', () => {
|
||||||
|
const startedAt = 1000;
|
||||||
|
const duration = 1000;
|
||||||
|
const expectedFinish = startedAt + duration;
|
||||||
|
const previousTime = expectedFinish - testSkipLimit / 2;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock += testSkipLimit;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows rolling backwards in an event', () => {
|
||||||
|
const startedAt = 1000;
|
||||||
|
const duration = 1000;
|
||||||
|
const expectedFinish = startedAt + duration;
|
||||||
|
const previousTime = startedAt + testSkipLimit / 2;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock -= testSkipLimit;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for crossing midnight', () => {
|
||||||
|
const startedAt = dayInMs - testSkipLimit;
|
||||||
|
const expectedFinish = 10;
|
||||||
|
const previousTime = dayInMs - 1;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock = testSkipLimit - 2;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows rolling backwards in an event across midnight', () => {
|
||||||
|
const startedAt = dayInMs - testSkipLimit;
|
||||||
|
const expectedFinish = 10;
|
||||||
|
const previousTime = startedAt + 1;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock -= testSkipLimit;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds skip forwards out of event', () => {
|
||||||
|
const startedAt = 1000;
|
||||||
|
const duration = 1000;
|
||||||
|
const expectedFinish = startedAt + duration;
|
||||||
|
const previousTime = expectedFinish - testSkipLimit / 2;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock += testSkipLimit + 1;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds skip backwards out of event', () => {
|
||||||
|
const startedAt = 1000;
|
||||||
|
const duration = 1000;
|
||||||
|
const expectedFinish = startedAt + duration;
|
||||||
|
const previousTime = startedAt + testSkipLimit / 2;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock -= testSkipLimit + 1;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds skip forwards out of event across midnight', () => {
|
||||||
|
const startedAt = dayInMs - testSkipLimit;
|
||||||
|
const expectedFinish = 10;
|
||||||
|
const previousTime = dayInMs - 3;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock = testSkipLimit - 2;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds skip backwards out of event across midnight', () => {
|
||||||
|
const startedAt = dayInMs - testSkipLimit;
|
||||||
|
const expectedFinish = 10;
|
||||||
|
const previousTime = startedAt + 1;
|
||||||
|
|
||||||
|
let clock = previousTime;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(false);
|
||||||
|
|
||||||
|
clock -= testSkipLimit + 1;
|
||||||
|
expect(skippedOutOfEvent(previousTime, clock, startedAt, expectedFinish, testSkipLimit)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import got from 'got';
|
||||||
|
|
||||||
|
import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types';
|
||||||
|
|
||||||
|
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||||
|
import { parseTemplateNested } from './integrationUtils.js';
|
||||||
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
|
import { logger } from '../../classes/Logger.js';
|
||||||
|
import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js';
|
||||||
|
|
||||||
|
type Action = TimerLifeCycleKey | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Class contains logic towards outgoing HTTP communications
|
||||||
|
* @class
|
||||||
|
*/
|
||||||
|
export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
|
||||||
|
subscriptions: HttpSubscription;
|
||||||
|
constructor() {
|
||||||
|
this.subscriptions = dbModel.http.subscriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes httpClient
|
||||||
|
*/
|
||||||
|
init(config: HttpSettings) {
|
||||||
|
const { subscriptions, enabledOut } = config;
|
||||||
|
|
||||||
|
if (!enabledOut) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'HTTP output disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initSubscriptions(subscriptions);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `HTTP integration client ready`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Failed initialising HTTP integration: ${error}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initSubscriptions(subscriptionOptions: HttpSubscription) {
|
||||||
|
if (validateHttpSubscriptionObject(subscriptionOptions)) {
|
||||||
|
this.subscriptions = { ...subscriptionOptions };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(action: Action, state?: object) {
|
||||||
|
if (!action) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'HTTP called with no action',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// check subscriptions for action
|
||||||
|
const eventSubscriptions = this.subscriptions?.[action] || [];
|
||||||
|
|
||||||
|
eventSubscriptions.forEach((sub) => {
|
||||||
|
const { enabled, message } = sub;
|
||||||
|
if (enabled && message) {
|
||||||
|
const parsedMessage = parseTemplateNested(message, state || {});
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(parsedMessage);
|
||||||
|
this.emit(parsedUrl);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `${err}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(path: URL) {
|
||||||
|
try {
|
||||||
|
await got.get(path, {
|
||||||
|
retry: { limit: 0 },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(LogOrigin.Tx, `HTTP integration: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpIntegration = new HttpIntegration();
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { TimerLifeCycle, OscSubscription } from 'ontime-types';
|
import { TimerLifeCycle, Subscription } from 'ontime-types';
|
||||||
|
|
||||||
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
||||||
|
|
||||||
export default interface IIntegration {
|
export default interface IIntegration<T> {
|
||||||
subscriptions: OscSubscription;
|
subscriptions: Subscription<T>;
|
||||||
init: (config: unknown) => OperationReturn;
|
init: (config: unknown) => OperationReturn;
|
||||||
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
|
dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn;
|
||||||
emit: (...args: unknown[]) => unknown;
|
emit: (...args: unknown[]) => unknown;
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
|||||||
import { eventStore } from '../../stores/EventStore.js';
|
import { eventStore } from '../../stores/EventStore.js';
|
||||||
|
|
||||||
class IntegrationService {
|
class IntegrationService {
|
||||||
private integrations: IIntegration[];
|
private integrations: IIntegration<unknown>[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.integrations = [];
|
this.integrations = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
register(integrationService: IIntegration) {
|
register(integrationService: IIntegration<unknown>) {
|
||||||
this.integrations.push(integrationService);
|
this.integrations.push(integrationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
unregister(integrationService: IIntegration) {
|
unregister(integrationService: IIntegration<unknown>) {
|
||||||
this.integrations = this.integrations.filter((int) => int !== integrationService);
|
this.integrations = this.integrations.filter((int) => int !== integrationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { ArgumentType, Client, Message } from 'node-osc';
|
import { ArgumentType, Client, Message } from 'node-osc';
|
||||||
import { OSCSettings, OscSubscription } from 'ontime-types';
|
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types';
|
||||||
|
|
||||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||||
import { parseTemplateNested } from './integrationUtils.js';
|
import { parseTemplateNested } from './integrationUtils.js';
|
||||||
import { isObject } from '../../utils/varUtils.js';
|
import { isObject } from '../../utils/varUtils.js';
|
||||||
import { dbModel } from '../../models/dataModel.js';
|
import { dbModel } from '../../models/dataModel.js';
|
||||||
import { validateOscObject } from '../../utils/parserFunctions.js';
|
import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js';
|
||||||
|
|
||||||
type Action = TimerLifeCycleKey | string;
|
type Action = TimerLifeCycleKey | string;
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ type Action = TimerLifeCycleKey | string;
|
|||||||
* @description Class contains logic towards outgoing OSC communications
|
* @description Class contains logic towards outgoing OSC communications
|
||||||
* @class
|
* @class
|
||||||
*/
|
*/
|
||||||
export class OscIntegration implements IIntegration {
|
export class OscIntegration implements IIntegration<OscSubscriptionOptions> {
|
||||||
protected oscClient: null | Client;
|
protected oscClient: null | Client;
|
||||||
subscriptions: OscSubscription;
|
subscriptions: OscSubscription;
|
||||||
|
|
||||||
@@ -26,7 +26,15 @@ export class OscIntegration implements IIntegration {
|
|||||||
* Initializes oscClient
|
* Initializes oscClient
|
||||||
*/
|
*/
|
||||||
init(config: OSCSettings) {
|
init(config: OSCSettings) {
|
||||||
const { targetIP, portOut, subscriptions } = config;
|
const { targetIP, portOut, subscriptions, enabledOut } = config;
|
||||||
|
|
||||||
|
if (!enabledOut) {
|
||||||
|
this.oscClient?.close();
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'OSC output disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
this.initSubscriptions(subscriptions);
|
this.initSubscriptions(subscriptions);
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ export class OscIntegration implements IIntegration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initSubscriptions(subscriptionOptions: OscSubscription) {
|
initSubscriptions(subscriptionOptions: OscSubscription) {
|
||||||
if (validateOscObject(subscriptionOptions)) {
|
if (validateOscSubscriptionObject(subscriptionOptions)) {
|
||||||
this.subscriptions = { ...subscriptionOptions };
|
this.subscriptions = { ...subscriptionOptions };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,3 +64,20 @@ export function getCurrent(
|
|||||||
}
|
}
|
||||||
return startedAt + duration + addedTime + pausedTime - clock;
|
return startedAt + duration + addedTime + pausedTime - clock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function skippedOutOfEvent(
|
||||||
|
previousTime: number,
|
||||||
|
clock: number,
|
||||||
|
startedAt: number,
|
||||||
|
expectedFinish: number,
|
||||||
|
skipLimit: number,
|
||||||
|
): boolean {
|
||||||
|
const hasPassedMidnight = previousTime > dayInMs - skipLimit && clock < skipLimit;
|
||||||
|
const adjustedClock = hasPassedMidnight ? clock + dayInMs : clock;
|
||||||
|
|
||||||
|
const timeDifference = previousTime - adjustedClock;
|
||||||
|
const hasSkipped = Math.abs(timeDifference) > skipLimit;
|
||||||
|
const adjustedExpectedFinish = expectedFinish >= startedAt ? expectedFinish : expectedFinish + dayInMs;
|
||||||
|
|
||||||
|
return hasSkipped && (adjustedClock > adjustedExpectedFinish || adjustedClock < startedAt);
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export const currentDirectory = dirname(__dirname);
|
|||||||
|
|
||||||
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
|
const testDbStartDirectory = isTest ? '../' : getAppDataPath();
|
||||||
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
|
export const externalsStartDirectory = isProduction ? getAppDataPath() : join(currentDirectory, 'external');
|
||||||
|
//TODO: we only need one when they are all in the same folder
|
||||||
|
export const resolveExternalsDirectory = join(isProduction ? getAppDataPath() : currentDirectory, 'external');
|
||||||
// path to public db
|
// path to public db
|
||||||
export const resolveDbDirectory = join(
|
export const resolveDbDirectory = join(
|
||||||
testDbStartDirectory,
|
testDbStartDirectory,
|
||||||
@@ -80,11 +81,26 @@ export const pathToStartDb = isTest
|
|||||||
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
|
? join(currentDirectory, '../', config.database.testdb, config.database.filename)
|
||||||
: join(currentDirectory, '/preloaded-db/', config.database.filename);
|
: join(currentDirectory, '/preloaded-db/', config.database.filename);
|
||||||
|
|
||||||
|
//TODO: move all static files to the external directory
|
||||||
// path to public styles
|
// path to public styles
|
||||||
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
export const resolveStylesDirectory = join(externalsStartDirectory, config.styles.directory);
|
||||||
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
export const resolveStylesPath = join(resolveStylesDirectory, config.styles.filename);
|
||||||
|
|
||||||
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
|
export const pathToStartStyles = join(currentDirectory, '/external/styles/', config.styles.filename);
|
||||||
|
|
||||||
|
// path to public demo
|
||||||
|
export const resolveDemoDirectory = join(
|
||||||
|
externalsStartDirectory,
|
||||||
|
isProduction ? '/external/' : '', //move to external folde in production
|
||||||
|
config.demo.directory,
|
||||||
|
);
|
||||||
|
export const resolveDemoPath = config.demo.filename.map((file) => {
|
||||||
|
return join(resolveDemoDirectory, file);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pathToStartDemo = config.demo.filename.map((file) => {
|
||||||
|
return join(currentDirectory, '/external/demo/', file);
|
||||||
|
});
|
||||||
|
|
||||||
// path to restore file
|
// path to restore file
|
||||||
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
|
export const resolveRestoreFile = join(getAppDataPath(), config.restoreFile);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { coerceColour } from '../coerceType.js';
|
||||||
|
|
||||||
|
describe('parses a colour string that is', () => {
|
||||||
|
it('valid hex', () => {
|
||||||
|
const color = coerceColour('#000');
|
||||||
|
expect(color).toBe('#000');
|
||||||
|
});
|
||||||
|
it('valid name', () => {
|
||||||
|
const color = coerceColour('darkgoldenrod');
|
||||||
|
expect(color).toBe('darkgoldenrod');
|
||||||
|
});
|
||||||
|
it('invalid hex', () => {
|
||||||
|
expect(() => coerceColour('#not a hex color')).toThrowError(Error('Invalid hex colour received'));
|
||||||
|
});
|
||||||
|
it('invalid name', () => {
|
||||||
|
expect(() => coerceColour('bad name')).toThrowError(Error('Invalid colour name received'));
|
||||||
|
});
|
||||||
|
it('not a string', () => {
|
||||||
|
expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { validateOscObject } from '../parserFunctions.ts';
|
|
||||||
|
|
||||||
test('validateOscSubscription()', () => {
|
|
||||||
it('should return true when given a valid OscSubscription', () => {
|
|
||||||
const validSubscription = {
|
|
||||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
|
||||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
|
||||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
|
||||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
|
||||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
|
||||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateOscObject(validSubscription);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given undefined', () => {
|
|
||||||
const result = validateOscObject(undefined);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given null', () => {
|
|
||||||
const result = validateOscObject(null);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given an empty object', () => {
|
|
||||||
const result = validateOscObject({});
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given an empty array', () => {
|
|
||||||
const result = validateOscObject([]);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given an object that is not an OscSubscription', () => {
|
|
||||||
const invalidObject = { foo: 'bar' };
|
|
||||||
|
|
||||||
const result = validateOscObject(invalidObject);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given an OscSubscription with a missing property', () => {
|
|
||||||
const invalidSubscription = {
|
|
||||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
|
||||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
|
||||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
|
||||||
// Missing onStop
|
|
||||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
|
||||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateOscObject(invalidSubscription);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when given an OscSubscription with an invalid property value', () => {
|
|
||||||
const invalidSubscription = {
|
|
||||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
|
||||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
|
||||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
|
||||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
|
||||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
|
||||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateOscObject(invalidSubscription);
|
|
||||||
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true if the message field is empty', () => {
|
|
||||||
const invalidSubscription = {
|
|
||||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
|
||||||
onStart: [{ id: '2', message: '', enabled: false }],
|
|
||||||
onPause: [{ id: '3', message: '', enabled: true }],
|
|
||||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
|
||||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
|
||||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = validateOscObject(invalidSubscription);
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { HttpSubscription, OscSubscription } from 'ontime-types';
|
||||||
|
import {
|
||||||
|
validateOscSubscriptionObject,
|
||||||
|
validateOscSubscriptionCycle,
|
||||||
|
validateHttpSubscriptionCycle,
|
||||||
|
validateHttpSubscriptionObject,
|
||||||
|
} from '../parserFunctions.js';
|
||||||
|
|
||||||
|
describe('validateOscSubscriptionCycle()', () => {
|
||||||
|
it('should return false when given an OscSubscription with an invalid property value', () => {
|
||||||
|
const invalidEntry = [{ message: 'test', enabled: 'not a boolean' }];
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionCycle(invalidEntry);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateOscSubscriptionObject()', () => {
|
||||||
|
it('should return true when given a valid OscSubscription', () => {
|
||||||
|
const validSubscription: OscSubscription = {
|
||||||
|
onLoad: [{ message: 'test', enabled: true }],
|
||||||
|
onStart: [{ message: 'test', enabled: false }],
|
||||||
|
onPause: [{ message: 'test', enabled: true }],
|
||||||
|
onStop: [{ message: 'test', enabled: false }],
|
||||||
|
onUpdate: [{ message: 'test', enabled: true }],
|
||||||
|
onFinish: [{ message: 'test', enabled: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateOscSubscriptionObject(validSubscription);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given undefined', () => {
|
||||||
|
const result = validateOscSubscriptionObject(undefined);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given null', () => {
|
||||||
|
const result = validateOscSubscriptionObject(null);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an empty object', () => {
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionObject({});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an empty array', () => {
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionObject([]);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an object that is not an OscSubscription', () => {
|
||||||
|
const invalidObject = { foo: 'bar' };
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionObject(invalidObject);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an OscSubscription with a missing property', () => {
|
||||||
|
const invalidSubscription = {
|
||||||
|
onLoad: [{ message: 'test', enabled: true }],
|
||||||
|
onStart: [{ message: 'test', enabled: false }],
|
||||||
|
onPause: [{ message: 'test', enabled: true }],
|
||||||
|
// Missing onStop
|
||||||
|
onUpdate: [{ message: 'test', enabled: true }],
|
||||||
|
onFinish: [{ message: 'test', enabled: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionObject(invalidSubscription);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateHttpSubscriptionCycle()', () => {
|
||||||
|
it('should return false when given an HttpSubscription with an invalid property value', () => {
|
||||||
|
const invalidBoolean = [{ message: 'http://', enabled: 'not a boolean' }];
|
||||||
|
const invalidHttp = [{ message: 'test', enabled: true }];
|
||||||
|
const noFtp = [{ message: 'ftp://test', enabled: true }];
|
||||||
|
const noEmpty = [{ message: '', enabled: true }];
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
expect(validateHttpSubscriptionCycle(invalidBoolean)).toBe(false);
|
||||||
|
|
||||||
|
expect(validateHttpSubscriptionCycle(invalidHttp)).toBe(false);
|
||||||
|
expect(validateHttpSubscriptionCycle(noFtp)).toBe(false);
|
||||||
|
expect(validateHttpSubscriptionCycle(noEmpty)).toBe(false);
|
||||||
|
});
|
||||||
|
it('should return true when given an HttpSubscription matches definition', () => {
|
||||||
|
const validHttp = [{ message: 'http://', enabled: true }];
|
||||||
|
const invalidHttps = [{ message: 'https://', enabled: true }];
|
||||||
|
|
||||||
|
expect(validateHttpSubscriptionCycle(validHttp)).toBe(true);
|
||||||
|
expect(validateHttpSubscriptionCycle(invalidHttps)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateHttpSubscriptionObject()', () => {
|
||||||
|
it('should return true when given a valid HttpSubscription', () => {
|
||||||
|
const validSubscription: HttpSubscription = {
|
||||||
|
onLoad: [{ message: 'http://', enabled: true }],
|
||||||
|
onStart: [{ message: 'http://', enabled: false }],
|
||||||
|
onPause: [{ message: 'http://', enabled: true }],
|
||||||
|
onStop: [{ message: 'http://', enabled: false }],
|
||||||
|
onUpdate: [{ message: 'http://', enabled: true }],
|
||||||
|
onFinish: [{ message: 'http://', enabled: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = validateHttpSubscriptionObject(validSubscription);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given undefined', () => {
|
||||||
|
const result = validateHttpSubscriptionObject(undefined);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given null', () => {
|
||||||
|
const result = validateHttpSubscriptionObject(null);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an empty object', () => {
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateOscSubscriptionObject({});
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an empty array', () => {
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateHttpSubscriptionObject([]);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an object that is not an HttpSubscription', () => {
|
||||||
|
const invalidObject = { foo: 'bar' };
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateHttpSubscriptionObject(invalidObject);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when given an HttpSubscription with a missing property', () => {
|
||||||
|
const invalidSubscription = {
|
||||||
|
onLoad: [{ message: 'http://', enabled: true }],
|
||||||
|
onStart: [{ message: 'http://', enabled: false }],
|
||||||
|
onPause: [{ message: 'http://', enabled: true }],
|
||||||
|
// Missing onStop
|
||||||
|
onUpdate: [{ message: 'http://', enabled: true }],
|
||||||
|
onFinish: [{ message: 'http://', enabled: false }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error -- since this comes from the client, we check things that typescript would have caught
|
||||||
|
const result = validateHttpSubscriptionObject(invalidSubscription);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
import { getA1Notation, cellRequestFromEvent, cellRequenstFromProjectData } from '../sheetUtils.js';
|
||||||
|
import { EndAction, OntimeRundownEntry, ProjectData, SupportedEvent, TimerType } from 'ontime-types';
|
||||||
|
|
||||||
|
describe('getA1Notation()', () => {
|
||||||
|
test('A1', () => {
|
||||||
|
expect(getA1Notation(0, 0)).toStrictEqual('A1');
|
||||||
|
});
|
||||||
|
test('E3', () => {
|
||||||
|
expect(getA1Notation(2, 4)).toStrictEqual('E3');
|
||||||
|
});
|
||||||
|
test('AA100', () => {
|
||||||
|
expect(getA1Notation(99, 26)).toStrictEqual('AA100');
|
||||||
|
});
|
||||||
|
test('can not be negative', () => {
|
||||||
|
expect(() => getA1Notation(-1, 1)).toThrowError('Index can not be less than 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cellRequenstFromEvent()', () => {
|
||||||
|
test('string to string', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: false,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: '',
|
||||||
|
user1: '',
|
||||||
|
user2: '',
|
||||||
|
user3: '',
|
||||||
|
user4: '',
|
||||||
|
user5: '',
|
||||||
|
user6: '',
|
||||||
|
user7: '',
|
||||||
|
user8: '',
|
||||||
|
user9: '',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
type: { row: 1, col: 14 },
|
||||||
|
cue: { row: 1, col: 15 },
|
||||||
|
title: { row: 1, col: 16 },
|
||||||
|
subtitle: { row: 1, col: 17 },
|
||||||
|
presenter: { row: 1, col: 18 },
|
||||||
|
note: { row: 1, col: 19 },
|
||||||
|
timeStart: { row: 1, col: 20 },
|
||||||
|
timeEnd: { row: 1, col: 21 },
|
||||||
|
endAction: { row: 1, col: 22 },
|
||||||
|
timerType: { row: 1, col: 23 },
|
||||||
|
duration: { row: 1, col: 24 },
|
||||||
|
isPublic: { row: 1, col: 25 },
|
||||||
|
skip: { row: 1, col: 26 },
|
||||||
|
colour: { row: 1, col: 27 },
|
||||||
|
user0: { row: 1, col: 28 },
|
||||||
|
user1: { row: 1, col: 29 },
|
||||||
|
user2: { row: 1, col: 30 },
|
||||||
|
user3: { row: 1, col: 31 },
|
||||||
|
user4: { row: 1, col: 32 },
|
||||||
|
user5: { row: 1, col: 33 },
|
||||||
|
user6: { row: 1, col: 34 },
|
||||||
|
user7: { row: 1, col: 35 },
|
||||||
|
user8: { row: 1, col: 36 },
|
||||||
|
user9: { row: 1, col: 37 },
|
||||||
|
revision: { row: 1, col: 38 },
|
||||||
|
id: { row: 1, col: 39 },
|
||||||
|
};
|
||||||
|
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.note);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numer to timer', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: false,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: '',
|
||||||
|
user1: '',
|
||||||
|
user2: '',
|
||||||
|
user3: '',
|
||||||
|
user4: '',
|
||||||
|
user5: '',
|
||||||
|
user6: '',
|
||||||
|
user7: '',
|
||||||
|
user8: '',
|
||||||
|
user9: '',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
type: { row: 1, col: 14 },
|
||||||
|
cue: { row: 1, col: 15 },
|
||||||
|
title: { row: 1, col: 16 },
|
||||||
|
subtitle: { row: 1, col: 17 },
|
||||||
|
presenter: { row: 1, col: 18 },
|
||||||
|
note: { row: 1, col: 19 },
|
||||||
|
timeStart: { row: 1, col: 20 },
|
||||||
|
timeEnd: { row: 1, col: 21 },
|
||||||
|
endAction: { row: 1, col: 22 },
|
||||||
|
timerType: { row: 1, col: 23 },
|
||||||
|
duration: { row: 1, col: 24 },
|
||||||
|
isPublic: { row: 1, col: 25 },
|
||||||
|
skip: { row: 1, col: 26 },
|
||||||
|
colour: { row: 1, col: 27 },
|
||||||
|
user0: { row: 1, col: 28 },
|
||||||
|
user1: { row: 1, col: 29 },
|
||||||
|
user2: { row: 1, col: 30 },
|
||||||
|
user3: { row: 1, col: 31 },
|
||||||
|
user4: { row: 1, col: 32 },
|
||||||
|
user5: { row: 1, col: 33 },
|
||||||
|
user6: { row: 1, col: 34 },
|
||||||
|
user7: { row: 1, col: 35 },
|
||||||
|
user8: { row: 1, col: 36 },
|
||||||
|
user9: { row: 1, col: 37 },
|
||||||
|
revision: { row: 1, col: 38 },
|
||||||
|
id: { row: 1, col: 39 },
|
||||||
|
};
|
||||||
|
const result = cellRequestFromEvent(event, 1, 1234, metadata).updateCells.rows[0].values[10].userEnteredValue
|
||||||
|
.stringValue;
|
||||||
|
expect(result).toStrictEqual(millisToString(event.duration));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('boolean to x', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: true,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: 'u',
|
||||||
|
user1: 'u',
|
||||||
|
user2: 'u',
|
||||||
|
user3: 'u',
|
||||||
|
user4: 'u',
|
||||||
|
user5: 'u',
|
||||||
|
user6: 'u',
|
||||||
|
user7: 'u',
|
||||||
|
user8: 'u',
|
||||||
|
user9: 'u',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
type: { row: 1, col: 14 },
|
||||||
|
cue: { row: 1, col: 15 },
|
||||||
|
title: { row: 1, col: 16 },
|
||||||
|
subtitle: { row: 1, col: 17 },
|
||||||
|
presenter: { row: 1, col: 18 },
|
||||||
|
note: { row: 1, col: 19 },
|
||||||
|
timeStart: { row: 1, col: 20 },
|
||||||
|
timeEnd: { row: 1, col: 21 },
|
||||||
|
endAction: { row: 1, col: 22 },
|
||||||
|
timerType: { row: 1, col: 23 },
|
||||||
|
duration: { row: 1, col: 24 },
|
||||||
|
isPublic: { row: 1, col: 25 },
|
||||||
|
skip: { row: 1, col: 26 },
|
||||||
|
colour: { row: 1, col: 27 },
|
||||||
|
user0: { row: 1, col: 28 },
|
||||||
|
user1: { row: 1, col: 29 },
|
||||||
|
user2: { row: 1, col: 30 },
|
||||||
|
user3: { row: 1, col: 31 },
|
||||||
|
user4: { row: 1, col: 32 },
|
||||||
|
user5: { row: 1, col: 33 },
|
||||||
|
user6: { row: 1, col: 34 },
|
||||||
|
user7: { row: 1, col: 35 },
|
||||||
|
user8: { row: 1, col: 36 },
|
||||||
|
user9: { row: 1, col: 37 },
|
||||||
|
revision: { row: 1, col: 38 },
|
||||||
|
id: { row: 1, col: 39 },
|
||||||
|
};
|
||||||
|
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[11].userEnteredValue.stringValue).toStrictEqual('x');
|
||||||
|
expect(result.updateCells.rows[0].values[12].userEnteredValue.stringValue).toStrictEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spacing in metadata', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: true,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: 'u',
|
||||||
|
user1: 'u',
|
||||||
|
user2: 'u',
|
||||||
|
user3: 'u',
|
||||||
|
user4: 'u',
|
||||||
|
user5: 'u',
|
||||||
|
user6: 'u',
|
||||||
|
user7: 'u',
|
||||||
|
user8: 'u',
|
||||||
|
user9: 'u',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
cue: { row: 1, col: 0 },
|
||||||
|
title: { row: 1, col: 6 },
|
||||||
|
subtitle: { row: 1, col: 10 },
|
||||||
|
user0: { row: 1, col: 16 },
|
||||||
|
};
|
||||||
|
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
|
||||||
|
expect(result.updateCells.rows[0].values[6].userEnteredValue.stringValue).toStrictEqual(event.title);
|
||||||
|
expect(result.updateCells.rows[0].values[10].userEnteredValue.stringValue).toStrictEqual(event.subtitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata offset from zero', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: true,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: 'u',
|
||||||
|
user1: 'u',
|
||||||
|
user2: 'u',
|
||||||
|
user3: 'u',
|
||||||
|
user4: 'u',
|
||||||
|
user5: 'u',
|
||||||
|
user6: 'u',
|
||||||
|
user7: 'u',
|
||||||
|
user8: 'u',
|
||||||
|
user9: 'u',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
cue: { row: 1, col: 5 },
|
||||||
|
title: { row: 1, col: 6 },
|
||||||
|
subtitle: { row: 1, col: 10 },
|
||||||
|
user0: { row: 1, col: 16 },
|
||||||
|
};
|
||||||
|
const result = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(event.cue);
|
||||||
|
expect(result.updateCells.rows[0].values[1].userEnteredValue.stringValue).toStrictEqual(event.title);
|
||||||
|
expect(result.updateCells.rows[0].values[5].userEnteredValue.stringValue).toStrictEqual(event.subtitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sheet setup', () => {
|
||||||
|
const event: OntimeRundownEntry = {
|
||||||
|
type: SupportedEvent.Event,
|
||||||
|
cue: '1',
|
||||||
|
title: 'Fancy',
|
||||||
|
subtitle: 'Wow',
|
||||||
|
presenter: 'Mr. Presenter',
|
||||||
|
note: 'Blue button on the right',
|
||||||
|
timeStart: 46800000,
|
||||||
|
timeEnd: 57600000,
|
||||||
|
endAction: EndAction.None,
|
||||||
|
timerType: TimerType.CountDown,
|
||||||
|
duration: 10800000,
|
||||||
|
isPublic: true,
|
||||||
|
skip: false,
|
||||||
|
colour: 'red',
|
||||||
|
user0: 'u',
|
||||||
|
user1: 'u',
|
||||||
|
user2: 'u',
|
||||||
|
user3: 'u',
|
||||||
|
user4: 'u',
|
||||||
|
user5: 'u',
|
||||||
|
user6: 'u',
|
||||||
|
user7: 'u',
|
||||||
|
user8: 'u',
|
||||||
|
user9: 'u',
|
||||||
|
revision: 0,
|
||||||
|
id: '1358',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
cue: { row: 10, col: 5 },
|
||||||
|
title: { row: 10, col: 6 },
|
||||||
|
subtitle: { row: 1, col: 10 },
|
||||||
|
user0: { row: 10, col: 16 },
|
||||||
|
};
|
||||||
|
const result1 = cellRequestFromEvent(event, 1, 1234, metadata);
|
||||||
|
expect(result1.updateCells.start.sheetId).toStrictEqual(1234);
|
||||||
|
const result2 = cellRequestFromEvent(event, 10, 1234, metadata);
|
||||||
|
expect(result2.updateCells.start.rowIndex).toStrictEqual(21);
|
||||||
|
expect(result2.updateCells.start.columnIndex).toStrictEqual(5);
|
||||||
|
expect(result2.updateCells.fields).toStrictEqual('userEnteredValue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cellRequenstFromProjectData()', () => {
|
||||||
|
test('string to string', () => {
|
||||||
|
const projectData: ProjectData = {
|
||||||
|
title: 'Title',
|
||||||
|
description: 'Description',
|
||||||
|
publicUrl: 'Public Url',
|
||||||
|
backstageUrl: 'Backstage Url',
|
||||||
|
publicInfo: 'Public Info',
|
||||||
|
backstageInfo: 'Backstage Info',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
title: { row: 0, col: 1 },
|
||||||
|
description: { row: 1, col: 1 },
|
||||||
|
publicUrl: { row: 2, col: 1 },
|
||||||
|
backstageUrl: { row: 3, col: 1 },
|
||||||
|
publicInfo: { row: 4, col: 1 },
|
||||||
|
backstageInfo: { row: 5, col: 1 },
|
||||||
|
};
|
||||||
|
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
|
||||||
|
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
|
||||||
|
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
|
||||||
|
expect(result.updateCells.rows[3].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
|
||||||
|
expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
|
||||||
|
expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metadata offset from zero', () => {
|
||||||
|
const projectData: ProjectData = {
|
||||||
|
title: 'Title',
|
||||||
|
description: 'Description',
|
||||||
|
publicUrl: 'Public Url',
|
||||||
|
backstageUrl: 'Backstage Url',
|
||||||
|
publicInfo: 'Public Info',
|
||||||
|
backstageInfo: 'Backstage Info',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
title: { row: 5, col: 10 },
|
||||||
|
description: { row: 6, col: 10 },
|
||||||
|
publicUrl: { row: 7, col: 10 },
|
||||||
|
backstageUrl: { row: 9, col: 10 },
|
||||||
|
publicInfo: { row: 10, col: 10 },
|
||||||
|
backstageInfo: { row: 11, col: 10 },
|
||||||
|
};
|
||||||
|
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
|
||||||
|
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
|
||||||
|
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
|
||||||
|
expect(result.updateCells.rows[4].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
|
||||||
|
expect(result.updateCells.rows[5].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
|
||||||
|
expect(result.updateCells.rows[6].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('spacing in metadata', () => {
|
||||||
|
const projectData: ProjectData = {
|
||||||
|
title: 'Title',
|
||||||
|
description: 'Description',
|
||||||
|
publicUrl: 'Public Url',
|
||||||
|
backstageUrl: 'Backstage Url',
|
||||||
|
publicInfo: 'Public Info',
|
||||||
|
backstageInfo: 'Backstage Info',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
title: { row: 0, col: 1 },
|
||||||
|
description: { row: 1, col: 1 },
|
||||||
|
publicUrl: { row: 2, col: 1 },
|
||||||
|
backstageUrl: { row: 9, col: 1 },
|
||||||
|
publicInfo: { row: 15, col: 1 },
|
||||||
|
backstageInfo: { row: 50, col: 1 },
|
||||||
|
};
|
||||||
|
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
|
||||||
|
expect(result.updateCells.rows[0].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.title);
|
||||||
|
expect(result.updateCells.rows[1].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.description);
|
||||||
|
expect(result.updateCells.rows[2].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicUrl);
|
||||||
|
expect(result.updateCells.rows[9].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageUrl);
|
||||||
|
expect(result.updateCells.rows[15].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.publicInfo);
|
||||||
|
expect(result.updateCells.rows[50].values[0].userEnteredValue.stringValue).toStrictEqual(projectData.backstageInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sheet setup', () => {
|
||||||
|
const projectData: ProjectData = {
|
||||||
|
title: 'Title',
|
||||||
|
description: 'Description',
|
||||||
|
publicUrl: 'Public Url',
|
||||||
|
backstageUrl: 'Backstage Url',
|
||||||
|
publicInfo: 'Public Info',
|
||||||
|
backstageInfo: 'Backstage Info',
|
||||||
|
};
|
||||||
|
const metadata = {
|
||||||
|
title: { row: 0, col: 10 },
|
||||||
|
description: { row: 1, col: 10 },
|
||||||
|
publicUrl: { row: 2, col: 10 },
|
||||||
|
backstageUrl: { row: 3, col: 10 },
|
||||||
|
publicInfo: { row: 4, col: 10 },
|
||||||
|
backstageInfo: { row: 5, col: 10 },
|
||||||
|
};
|
||||||
|
const result = cellRequenstFromProjectData(projectData, 1234, metadata);
|
||||||
|
expect(result.updateCells.start.rowIndex).toStrictEqual(0);
|
||||||
|
expect(result.updateCells.start.columnIndex).toStrictEqual(11);
|
||||||
|
expect(result.updateCells.fields).toStrictEqual('userEnteredValue');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { isColourHex } from 'ontime-utils';
|
||||||
|
|
||||||
|
//TODO: write tests
|
||||||
/**
|
/**
|
||||||
* @description Converts a value to a number if possible, throws otherwise
|
* @description Converts a value to a string if possible, throws otherwise
|
||||||
* @param {unknown} value - Value to be converted to a string.
|
* @param {unknown} value - Value to be converted to a string.
|
||||||
* @returns {string} - The converted value as a string.
|
* @returns {string} - The converted value as a string.
|
||||||
* @throws {Error} Throws an error if the value is null or undefined.
|
* @throws {Error} Throws an error if the value is null or undefined.
|
||||||
@@ -11,8 +14,9 @@ export function coerceString(value: unknown): string {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: write tests
|
||||||
/**
|
/**
|
||||||
* @description Converts a value to a number if possible, throws otherwise
|
* @description Converts a value to a boolean if possible, throws otherwise
|
||||||
* @param {unknown} value - Value to be converted to a boolean.
|
* @param {unknown} value - Value to be converted to a boolean.
|
||||||
* @returns {boolean} - The converted value as a boolean.
|
* @returns {boolean} - The converted value as a boolean.
|
||||||
* @throws {Error} Throws an error if the value is null or undefined.
|
* @throws {Error} Throws an error if the value is null or undefined.
|
||||||
@@ -21,9 +25,26 @@ export function coerceBoolean(value: unknown): boolean {
|
|||||||
if (value == null) {
|
if (value == null) {
|
||||||
throw new Error('Invalid value received');
|
throw new Error('Invalid value received');
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const lowerCaseValue = value.toLocaleLowerCase();
|
||||||
|
switch (lowerCaseValue) {
|
||||||
|
case 'true':
|
||||||
|
case '1':
|
||||||
|
case 'yes':
|
||||||
|
return true;
|
||||||
|
case 'false':
|
||||||
|
case '0':
|
||||||
|
case 'no':
|
||||||
|
case '':
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
throw new Error('Invalid value received');
|
||||||
|
}
|
||||||
|
}
|
||||||
return Boolean(value);
|
return Boolean(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO: write tests
|
||||||
/**
|
/**
|
||||||
* @description Converts a value to a number if possible, throws otherwise
|
* @description Converts a value to a number if possible, throws otherwise
|
||||||
* @param {unknown} value - Value to be converted to a number.
|
* @param {unknown} value - Value to be converted to a number.
|
||||||
@@ -40,3 +61,176 @@ export function coerceNumber(value: unknown): number {
|
|||||||
}
|
}
|
||||||
return parsedValue;
|
return parsedValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Converts a value to a colour if possible, throws otherwise
|
||||||
|
* @param {unknown} value - Value to be converted to a colour.
|
||||||
|
* @returns {string} - The converted value as a string.
|
||||||
|
* @throws {Error} Throws an error if the value is null or undefined.
|
||||||
|
*/
|
||||||
|
export function coerceColour(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error('Invalid colour value received');
|
||||||
|
}
|
||||||
|
const lowerCaseValue = value.toLocaleLowerCase();
|
||||||
|
if (lowerCaseValue.startsWith('#')) {
|
||||||
|
if (!isColourHex(lowerCaseValue)) {
|
||||||
|
throw new Error('Invalid hex colour received');
|
||||||
|
}
|
||||||
|
} else if (!(lowerCaseValue in cssColours)) {
|
||||||
|
throw new Error('Invalid colour name received');
|
||||||
|
}
|
||||||
|
return lowerCaseValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
//https://developer.mozilla.org/en-US/docs/Web/CSS/named-color
|
||||||
|
const cssColours = {
|
||||||
|
aliceblue: '#f0f8ff',
|
||||||
|
antiquewhite: '#faebd7',
|
||||||
|
aqua: '#00ffff',
|
||||||
|
aquamarine: '#7fffd4',
|
||||||
|
azure: '#f0ffff',
|
||||||
|
beige: '#f5f5dc',
|
||||||
|
bisque: '#ffe4c4',
|
||||||
|
black: '#000000',
|
||||||
|
blanchedalmond: '#ffebcd',
|
||||||
|
blue: '#0000ff',
|
||||||
|
blueviolet: '#8a2be2',
|
||||||
|
brown: '#a52a2a',
|
||||||
|
burlywood: '#deb887',
|
||||||
|
cadetblue: '#5f9ea0',
|
||||||
|
chartreuse: '#7fff00',
|
||||||
|
chocolate: '#d2691e',
|
||||||
|
coral: '#ff7f50',
|
||||||
|
cornflowerblue: '#6495ed',
|
||||||
|
cornsilk: '#fff8dc',
|
||||||
|
crimson: '#dc143c',
|
||||||
|
cyan: '#00ffff',
|
||||||
|
darkblue: '#00008b',
|
||||||
|
darkcyan: '#008b8b',
|
||||||
|
darkgoldenrod: '#b8860b',
|
||||||
|
darkgray: '#a9a9a9',
|
||||||
|
darkgreen: '#006400',
|
||||||
|
darkgrey: '#a9a9a9',
|
||||||
|
darkkhaki: '#bdb76b',
|
||||||
|
darkmagenta: '#8b008b',
|
||||||
|
darkolivegreen: '#556b2f',
|
||||||
|
darkorange: '#ff8c00',
|
||||||
|
darkorchid: '#9932cc',
|
||||||
|
darkred: '#8b0000',
|
||||||
|
darksalmon: '#e9967a',
|
||||||
|
darkseagreen: '#8fbc8f',
|
||||||
|
darkslateblue: '#483d8b',
|
||||||
|
darkslategray: '#2f4f4f',
|
||||||
|
darkslategrey: '#2f4f4f',
|
||||||
|
darkturquoise: '#00ced1',
|
||||||
|
darkviolet: '#9400d3',
|
||||||
|
deeppink: '#ff1493',
|
||||||
|
deepskyblue: '#00bfff',
|
||||||
|
dimgray: '#696969',
|
||||||
|
dimgrey: '#696969',
|
||||||
|
dodgerblue: '#1e90ff',
|
||||||
|
firebrick: '#b22222',
|
||||||
|
floralwhite: '#fffaf0',
|
||||||
|
forestgreen: '#228b22',
|
||||||
|
fuchsia: '#ff00ff',
|
||||||
|
gainsboro: '#dcdcdc',
|
||||||
|
ghostwhite: '#f8f8ff',
|
||||||
|
goldenrod: '#daa520',
|
||||||
|
gold: '#ffd700',
|
||||||
|
gray: '#808080',
|
||||||
|
green: '#008000',
|
||||||
|
greenyellow: '#adff2f',
|
||||||
|
grey: '#808080',
|
||||||
|
honeydew: '#f0fff0',
|
||||||
|
hotpink: '#ff69b4',
|
||||||
|
indianred: '#cd5c5c',
|
||||||
|
indigo: '#4b0082',
|
||||||
|
ivory: '#fffff0',
|
||||||
|
khaki: '#f0e68c',
|
||||||
|
lavenderblush: '#fff0f5',
|
||||||
|
lavender: '#e6e6fa',
|
||||||
|
lawngreen: '#7cfc00',
|
||||||
|
lemonchiffon: '#fffacd',
|
||||||
|
lightblue: '#add8e6',
|
||||||
|
lightcoral: '#f08080',
|
||||||
|
lightcyan: '#e0ffff',
|
||||||
|
lightgoldenrodyellow: '#fafad2',
|
||||||
|
lightgray: '#d3d3d3',
|
||||||
|
lightgreen: '#90ee90',
|
||||||
|
lightgrey: '#d3d3d3',
|
||||||
|
lightpink: '#ffb6c1',
|
||||||
|
lightsalmon: '#ffa07a',
|
||||||
|
lightseagreen: '#20b2aa',
|
||||||
|
lightskyblue: '#87cefa',
|
||||||
|
lightslategray: '#778899',
|
||||||
|
lightslategrey: '#778899',
|
||||||
|
lightsteelblue: '#b0c4de',
|
||||||
|
lightyellow: '#ffffe0',
|
||||||
|
lime: '#00ff00',
|
||||||
|
limegreen: '#32cd32',
|
||||||
|
linen: '#faf0e6',
|
||||||
|
magenta: '#ff00ff',
|
||||||
|
maroon: '#800000',
|
||||||
|
mediumaquamarine: '#66cdaa',
|
||||||
|
mediumblue: '#0000cd',
|
||||||
|
mediumorchid: '#ba55d3',
|
||||||
|
mediumpurple: '#9370db',
|
||||||
|
mediumseagreen: '#3cb371',
|
||||||
|
mediumslateblue: '#7b68ee',
|
||||||
|
mediumspringgreen: '#00fa9a',
|
||||||
|
mediumturquoise: '#48d1cc',
|
||||||
|
mediumvioletred: '#c71585',
|
||||||
|
midnightblue: '#191970',
|
||||||
|
mintcream: '#f5fffa',
|
||||||
|
mistyrose: '#ffe4e1',
|
||||||
|
moccasin: '#ffe4b5',
|
||||||
|
navajowhite: '#ffdead',
|
||||||
|
navy: '#000080',
|
||||||
|
oldlace: '#fdf5e6',
|
||||||
|
olive: '#808000',
|
||||||
|
olivedrab: '#6b8e23',
|
||||||
|
orange: '#ffa500',
|
||||||
|
orangered: '#ff4500',
|
||||||
|
orchid: '#da70d6',
|
||||||
|
palegoldenrod: '#eee8aa',
|
||||||
|
palegreen: '#98fb98',
|
||||||
|
paleturquoise: '#afeeee',
|
||||||
|
palevioletred: '#db7093',
|
||||||
|
papayawhip: '#ffefd5',
|
||||||
|
peachpuff: '#ffdab9',
|
||||||
|
peru: '#cd853f',
|
||||||
|
pink: '#ffc0cb',
|
||||||
|
plum: '#dda0dd',
|
||||||
|
powderblue: '#b0e0e6',
|
||||||
|
purple: '#800080',
|
||||||
|
rebeccapurple: '#663399',
|
||||||
|
red: '#ff0000',
|
||||||
|
rosybrown: '#bc8f8f',
|
||||||
|
royalblue: '#4169e1',
|
||||||
|
saddlebrown: '#8b4513',
|
||||||
|
salmon: '#fa8072',
|
||||||
|
sandybrown: '#f4a460',
|
||||||
|
seagreen: '#2e8b57',
|
||||||
|
seashell: '#fff5ee',
|
||||||
|
sienna: '#a0522d',
|
||||||
|
silver: '#c0c0c0',
|
||||||
|
skyblue: '#87ceeb',
|
||||||
|
slateblue: '#6a5acd',
|
||||||
|
slategray: '#708090',
|
||||||
|
slategrey: '#708090',
|
||||||
|
snow: '#fffafa',
|
||||||
|
springgreen: '#00ff7f',
|
||||||
|
steelblue: '#4682b4',
|
||||||
|
tan: '#d2b48c',
|
||||||
|
teal: '#008080',
|
||||||
|
thistle: '#d8bfd8',
|
||||||
|
tomato: '#ff6347',
|
||||||
|
turquoise: '#40e0d0',
|
||||||
|
violet: '#ee82ee',
|
||||||
|
wheat: '#f5deb3',
|
||||||
|
white: '#ffffff',
|
||||||
|
whitesmoke: '#f5f5f5',
|
||||||
|
yellow: '#ffff00',
|
||||||
|
yellowgreen: '#9acd3',
|
||||||
|
} as const;
|
||||||
|
|||||||
+217
-123
@@ -29,12 +29,14 @@ import {
|
|||||||
parseAliases,
|
parseAliases,
|
||||||
parseProject,
|
parseProject,
|
||||||
parseOsc,
|
parseOsc,
|
||||||
|
parseHttp,
|
||||||
parseRundown,
|
parseRundown,
|
||||||
parseSettings,
|
parseSettings,
|
||||||
parseUserFields,
|
parseUserFields,
|
||||||
parseViewSettings,
|
parseViewSettings,
|
||||||
} from './parserFunctions.js';
|
} from './parserFunctions.js';
|
||||||
import { parseExcelDate } from './time.js';
|
import { parseExcelDate } from './time.js';
|
||||||
|
import { coerceBoolean } from './coerceType.js';
|
||||||
|
|
||||||
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
export const JSON_MIME = 'application/json';
|
export const JSON_MIME = 'application/json';
|
||||||
@@ -46,6 +48,8 @@ export const JSON_MIME = 'application/json';
|
|||||||
* @returns {object} - parsed object
|
* @returns {object} - parsed object
|
||||||
*/
|
*/
|
||||||
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
|
export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImportMap>) => {
|
||||||
|
const projectMetadata = {};
|
||||||
|
const rundownMetadata = {};
|
||||||
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
const importMap: ExcelImportMap = { ...defaultExcelImportMap, ...options };
|
||||||
const projectData: Partial<ProjectData> = {
|
const projectData: Partial<ProjectData> = {
|
||||||
title: '',
|
title: '',
|
||||||
@@ -102,140 +106,228 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
|
|||||||
let user8Index: number | null = null;
|
let user8Index: number | null = null;
|
||||||
let user9Index: number | null = null;
|
let user9Index: number | null = null;
|
||||||
|
|
||||||
excelData
|
excelData.forEach((row, rowIndex) => {
|
||||||
.filter((e) => e.length > 0)
|
if (row.length === 0) {
|
||||||
.forEach((row) => {
|
return;
|
||||||
// these fields contain the data to its right
|
}
|
||||||
let projectTitleNext = false;
|
// these fields contain the data to its right
|
||||||
let projectDescriptionNext = false;
|
let projectTitleNext = false;
|
||||||
let publicUrlNext = false;
|
let projectDescriptionNext = false;
|
||||||
let publicInfoNext = false;
|
let publicUrlNext = false;
|
||||||
let backstageUrlNext = false;
|
let publicInfoNext = false;
|
||||||
let backstageInfoNext = false;
|
let backstageUrlNext = false;
|
||||||
|
let backstageInfoNext = false;
|
||||||
|
|
||||||
const event: Partial<OntimeEvent> = {};
|
const event: Partial<OntimeEvent> = {};
|
||||||
const handlers = {
|
const handlers = {
|
||||||
[importMap.projectName]: () => (projectTitleNext = true),
|
[importMap.projectName]: (row: number, col: number) => {
|
||||||
[importMap.projectDescription]: () => (projectDescriptionNext = true),
|
projectTitleNext = true;
|
||||||
[importMap.publicUrl]: () => (publicUrlNext = true),
|
projectMetadata['title'] = { row, col };
|
||||||
[importMap.publicInfo]: () => (publicInfoNext = true),
|
},
|
||||||
[importMap.backstageUrl]: () => (backstageUrlNext = true),
|
[importMap.projectDescription]: (row: number, col: number) => {
|
||||||
[importMap.backstageInfo]: () => (backstageInfoNext = true),
|
projectDescriptionNext = true;
|
||||||
|
projectMetadata['description'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.publicUrl]: (row: number, col: number) => {
|
||||||
|
publicUrlNext = true;
|
||||||
|
projectMetadata['publicUrl'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.publicInfo]: (row: number, col: number) => {
|
||||||
|
publicInfoNext = true;
|
||||||
|
projectMetadata['publicInfo'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.backstageUrl]: (row: number, col: number) => {
|
||||||
|
backstageUrlNext = true;
|
||||||
|
projectMetadata['backstageUrl'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.backstageInfo]: (row: number, col: number) => {
|
||||||
|
backstageInfoNext = true;
|
||||||
|
projectMetadata['backstageInfo'] = { row, col };
|
||||||
|
},
|
||||||
|
|
||||||
[importMap.timeStart]: (index: number) => (timeStartIndex = index),
|
[importMap.timeStart]: (row: number, col: number) => {
|
||||||
[importMap.timeEnd]: (index: number) => (timeEndIndex = index),
|
timeStartIndex = col;
|
||||||
[importMap.duration]: (index: number) => (durationIndex = index),
|
rundownMetadata['timeStart'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timeEnd]: (row: number, col: number) => {
|
||||||
|
timeEndIndex = col;
|
||||||
|
rundownMetadata['timeEnd'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.duration]: (row: number, col: number) => {
|
||||||
|
durationIndex = col;
|
||||||
|
rundownMetadata['duration'] = { row, col };
|
||||||
|
},
|
||||||
|
|
||||||
[importMap.cue]: (index: number) => (cueIndex = index),
|
[importMap.cue]: (row: number, col: number) => {
|
||||||
[importMap.title]: (index: number) => (titleIndex = index),
|
cueIndex = col;
|
||||||
[importMap.presenter]: (index: number) => (presenterIndex = index),
|
rundownMetadata['cue'] = { row, col };
|
||||||
[importMap.subtitle]: (index: number) => (subtitleIndex = index),
|
},
|
||||||
[importMap.isPublic]: (index: number) => (isPublicIndex = index),
|
[importMap.title]: (row: number, col: number) => {
|
||||||
[importMap.skip]: (index: number) => (skipIndex = index),
|
titleIndex = col;
|
||||||
[importMap.note]: (index: number) => (notesIndex = index),
|
rundownMetadata['title'] = { row, col };
|
||||||
[importMap.colour]: (index: number) => (colourIndex = index),
|
},
|
||||||
|
[importMap.presenter]: (row: number, col: number) => {
|
||||||
|
presenterIndex = col;
|
||||||
|
rundownMetadata['presenter'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.subtitle]: (row: number, col: number) => {
|
||||||
|
subtitleIndex = col;
|
||||||
|
rundownMetadata['subtitle'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.isPublic]: (row: number, col: number) => {
|
||||||
|
isPublicIndex = col;
|
||||||
|
rundownMetadata['isPublic'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.skip]: (row: number, col: number) => {
|
||||||
|
skipIndex = col;
|
||||||
|
rundownMetadata['skip'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.note]: (row: number, col: number) => {
|
||||||
|
notesIndex = col;
|
||||||
|
rundownMetadata['note'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.colour]: (row: number, col: number) => {
|
||||||
|
colourIndex = col;
|
||||||
|
rundownMetadata['colour'] = { row, col };
|
||||||
|
},
|
||||||
|
|
||||||
[importMap.endAction]: (index: number) => (endActionIndex = index),
|
[importMap.endAction]: (row: number, col: number) => {
|
||||||
[importMap.timerType]: (index: number) => (timerTypeIndex = index),
|
endActionIndex = col;
|
||||||
|
rundownMetadata['endAction'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.timerType]: (row: number, col: number) => {
|
||||||
|
timerTypeIndex = col;
|
||||||
|
rundownMetadata['timerType'] = { row, col };
|
||||||
|
},
|
||||||
|
|
||||||
[importMap.user0]: (index: number) => (user0Index = index),
|
[importMap.user0]: (row: number, col: number) => {
|
||||||
[importMap.user1]: (index: number) => (user1Index = index),
|
user0Index = col;
|
||||||
[importMap.user2]: (index: number) => (user2Index = index),
|
rundownMetadata['user0'] = { row, col };
|
||||||
[importMap.user3]: (index: number) => (user3Index = index),
|
},
|
||||||
[importMap.user4]: (index: number) => (user4Index = index),
|
[importMap.user1]: (row: number, col: number) => {
|
||||||
[importMap.user5]: (index: number) => (user5Index = index),
|
user1Index = col;
|
||||||
[importMap.user6]: (index: number) => (user6Index = index),
|
rundownMetadata['user1'] = { row, col };
|
||||||
[importMap.user7]: (index: number) => (user7Index = index),
|
},
|
||||||
[importMap.user8]: (index: number) => (user8Index = index),
|
[importMap.user2]: (row: number, col: number) => {
|
||||||
[importMap.user9]: (index: number) => (user9Index = index),
|
user2Index = col;
|
||||||
} as const;
|
rundownMetadata['user2'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user3]: (row: number, col: number) => {
|
||||||
|
user3Index = col;
|
||||||
|
rundownMetadata['user3'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user4]: (row: number, col: number) => {
|
||||||
|
user4Index = col;
|
||||||
|
rundownMetadata['user4'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user5]: (row: number, col: number) => {
|
||||||
|
user5Index = col;
|
||||||
|
rundownMetadata['user5'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user6]: (row: number, col: number) => {
|
||||||
|
user6Index = col;
|
||||||
|
rundownMetadata['user6'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user7]: (row: number, col: number) => {
|
||||||
|
user7Index = col;
|
||||||
|
rundownMetadata['user7'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user8]: (row: number, col: number) => {
|
||||||
|
user8Index = col;
|
||||||
|
rundownMetadata['user8'] = { row, col };
|
||||||
|
},
|
||||||
|
[importMap.user9]: (row: number, col: number) => {
|
||||||
|
user9Index = col;
|
||||||
|
rundownMetadata['user9'] = { row, col };
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
row.forEach((column, j) => {
|
row.forEach((column, j) => {
|
||||||
// 1. we check if we have set a flag for a known field
|
// 1. we check if we have set a flag for a known field
|
||||||
if (projectTitleNext) {
|
if (projectTitleNext) {
|
||||||
projectData.title = makeString(column, '');
|
projectData.title = makeString(column, '');
|
||||||
projectTitleNext = false;
|
projectTitleNext = false;
|
||||||
} else if (projectDescriptionNext) {
|
} else if (projectDescriptionNext) {
|
||||||
projectData.description = makeString(column, '');
|
projectData.description = makeString(column, '');
|
||||||
projectDescriptionNext = false;
|
projectDescriptionNext = false;
|
||||||
} else if (publicUrlNext) {
|
} else if (publicUrlNext) {
|
||||||
projectData.publicUrl = makeString(column, '');
|
projectData.publicUrl = makeString(column, '');
|
||||||
publicUrlNext = false;
|
publicUrlNext = false;
|
||||||
} else if (publicInfoNext) {
|
} else if (publicInfoNext) {
|
||||||
projectData.publicInfo = makeString(column, '');
|
projectData.publicInfo = makeString(column, '');
|
||||||
publicInfoNext = false;
|
publicInfoNext = false;
|
||||||
} else if (backstageUrlNext) {
|
} else if (backstageUrlNext) {
|
||||||
projectData.backstageUrl = makeString(column, '');
|
projectData.backstageUrl = makeString(column, '');
|
||||||
backstageUrlNext = false;
|
backstageUrlNext = false;
|
||||||
} else if (backstageInfoNext) {
|
} else if (backstageInfoNext) {
|
||||||
projectData.backstageInfo = makeString(column, '');
|
projectData.backstageInfo = makeString(column, '');
|
||||||
backstageInfoNext = false;
|
backstageInfoNext = false;
|
||||||
} else if (j === timeStartIndex) {
|
} else if (j === timeStartIndex) {
|
||||||
event.timeStart = parseExcelDate(column);
|
event.timeStart = parseExcelDate(column);
|
||||||
} else if (j === timeEndIndex) {
|
} else if (j === timeEndIndex) {
|
||||||
event.timeEnd = parseExcelDate(column);
|
event.timeEnd = parseExcelDate(column);
|
||||||
} else if (j === durationIndex) {
|
} else if (j === durationIndex) {
|
||||||
event.duration = parseExcelDate(column);
|
event.duration = parseExcelDate(column);
|
||||||
} else if (j === titleIndex) {
|
} else if (j === titleIndex) {
|
||||||
event.title = makeString(column, '');
|
event.title = makeString(column, '');
|
||||||
} else if (j === cueIndex) {
|
} else if (j === cueIndex) {
|
||||||
event.cue = makeString(column, '');
|
event.cue = makeString(column, '');
|
||||||
} else if (j === presenterIndex) {
|
} else if (j === presenterIndex) {
|
||||||
event.presenter = makeString(column, '');
|
event.presenter = makeString(column, '');
|
||||||
} else if (j === subtitleIndex) {
|
} else if (j === subtitleIndex) {
|
||||||
event.subtitle = makeString(column, '');
|
event.subtitle = makeString(column, '');
|
||||||
} else if (j === isPublicIndex) {
|
} else if (j === isPublicIndex) {
|
||||||
event.isPublic = Boolean(column);
|
event.isPublic = column == 'x' ? true : coerceBoolean(column);
|
||||||
} else if (j === skipIndex) {
|
} else if (j === skipIndex) {
|
||||||
event.skip = Boolean(column);
|
event.skip = column == 'x' ? true : coerceBoolean(column);
|
||||||
} else if (j === notesIndex) {
|
} else if (j === notesIndex) {
|
||||||
event.note = makeString(column, '');
|
event.note = makeString(column, '');
|
||||||
} else if (j === endActionIndex) {
|
} else if (j === endActionIndex) {
|
||||||
event.endAction = validateEndAction(column);
|
event.endAction = validateEndAction(column);
|
||||||
} else if (j === timerTypeIndex) {
|
} else if (j === timerTypeIndex) {
|
||||||
event.timerType = validateTimerType(column);
|
event.timerType = validateTimerType(column);
|
||||||
} else if (j === colourIndex) {
|
} else if (j === colourIndex) {
|
||||||
event.colour = makeString(column, '');
|
event.colour = makeString(column, '');
|
||||||
} else if (j === user0Index) {
|
} else if (j === user0Index) {
|
||||||
event.user0 = makeString(column, '');
|
event.user0 = makeString(column, '');
|
||||||
} else if (j === user1Index) {
|
} else if (j === user1Index) {
|
||||||
event.user1 = makeString(column, '');
|
event.user1 = makeString(column, '');
|
||||||
} else if (j === user2Index) {
|
} else if (j === user2Index) {
|
||||||
event.user2 = makeString(column, '');
|
event.user2 = makeString(column, '');
|
||||||
} else if (j === user3Index) {
|
} else if (j === user3Index) {
|
||||||
event.user3 = makeString(column, '');
|
event.user3 = makeString(column, '');
|
||||||
} else if (j === user4Index) {
|
} else if (j === user4Index) {
|
||||||
event.user4 = makeString(column, '');
|
event.user4 = makeString(column, '');
|
||||||
} else if (j === user5Index) {
|
} else if (j === user5Index) {
|
||||||
event.user5 = makeString(column, '');
|
event.user5 = makeString(column, '');
|
||||||
} else if (j === user6Index) {
|
} else if (j === user6Index) {
|
||||||
event.user6 = makeString(column, '');
|
event.user6 = makeString(column, '');
|
||||||
} else if (j === user7Index) {
|
} else if (j === user7Index) {
|
||||||
event.user7 = makeString(column, '');
|
event.user7 = makeString(column, '');
|
||||||
} else if (j === user8Index) {
|
} else if (j === user8Index) {
|
||||||
event.user8 = makeString(column, '');
|
event.user8 = makeString(column, '');
|
||||||
} else if (j === user9Index) {
|
} else if (j === user9Index) {
|
||||||
event.user9 = makeString(column, '');
|
event.user9 = makeString(column, '');
|
||||||
} else {
|
} else {
|
||||||
// 2. if there is no flag, lets see if we know the field type
|
// 2. if there is no flag, lets see if we know the field type
|
||||||
if (typeof column === 'string') {
|
if (typeof column === 'string') {
|
||||||
const col = column.toLowerCase();
|
const col = column.toLowerCase();
|
||||||
|
|
||||||
if (handlers[col]) {
|
if (handlers[col]) {
|
||||||
handlers[col](j);
|
handlers[col](rowIndex, j);
|
||||||
}
|
|
||||||
// else. we don't know how to handle this column
|
|
||||||
// just ignore it
|
|
||||||
}
|
}
|
||||||
|
// else. we don't know how to handle this column
|
||||||
|
// just ignore it
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (Object.keys(event).length > 0) {
|
|
||||||
// if any data was found, push to array
|
|
||||||
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (Object.keys(event).length > 0) {
|
||||||
|
// if any data was found, push to array
|
||||||
|
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rundown,
|
rundown,
|
||||||
project: projectData,
|
project: projectData,
|
||||||
@@ -244,6 +336,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ExcelImport
|
|||||||
version: '2.0.0',
|
version: '2.0.0',
|
||||||
},
|
},
|
||||||
userFields: customUserFields,
|
userFields: customUserFields,
|
||||||
|
projectMetadata,
|
||||||
|
rundownMetadata,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,7 +369,7 @@ export const parseJson = async (jsonData): Promise<DatabaseModel | null> => {
|
|||||||
// Import OSC settings if any
|
// Import OSC settings if any
|
||||||
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
returnData.osc = parseOsc(jsonData) ?? dbModel.osc;
|
||||||
// Import HTTP settings if any
|
// Import HTTP settings if any
|
||||||
// returnData.http = parseHttp(jsonData, enforce);
|
returnData.http = parseHttp(jsonData) ?? dbModel.http;
|
||||||
|
|
||||||
return returnData as DatabaseModel;
|
return returnData as DatabaseModel;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ import { generateId } from 'ontime-utils';
|
|||||||
import {
|
import {
|
||||||
Alias,
|
Alias,
|
||||||
OntimeRundown,
|
OntimeRundown,
|
||||||
|
HttpSettings,
|
||||||
OSCSettings,
|
OSCSettings,
|
||||||
OscSubscription,
|
|
||||||
OscSubscriptionOptions,
|
|
||||||
ProjectData,
|
ProjectData,
|
||||||
Settings,
|
Settings,
|
||||||
TimerLifeCycle,
|
TimerLifeCycle,
|
||||||
UserFields,
|
UserFields,
|
||||||
ViewSettings,
|
ViewSettings,
|
||||||
|
OscSubscription,
|
||||||
|
HttpSubscription,
|
||||||
|
OscSubscriptionOptions,
|
||||||
|
HttpSubscriptionOptions,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||||
@@ -159,12 +162,12 @@ export const parseViewSettings = (data): ViewSettings => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses and validates subscription entry
|
* Parses and validates OSC subscription cycle options
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): boolean => {
|
export const validateOscSubscriptionCycle = (data: OscSubscriptionOptions[]): boolean => {
|
||||||
for (const subscription in data) {
|
for (const subscriptionOption of data) {
|
||||||
if (typeof data[subscription].message !== 'string' || typeof data[subscription].enabled !== 'boolean') {
|
if (typeof subscriptionOption.message !== 'string' || typeof subscriptionOption.enabled !== 'boolean') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,22 +175,23 @@ export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): bool
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses and validates subscription object
|
* Parses and validates OSC subscription object
|
||||||
* @param data
|
* @param data
|
||||||
*/
|
*/
|
||||||
export const validateOscObject = (data: OscSubscription): boolean => {
|
export const validateOscSubscriptionObject = (data: OscSubscription): boolean => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timerKeys = Object.keys(TimerLifeCycle);
|
const timerKeys = Object.keys(TimerLifeCycle);
|
||||||
for (const key of timerKeys) {
|
for (const key of timerKeys) {
|
||||||
|
// must contains all keys and be an array
|
||||||
if (!(key in data) || !Array.isArray(data[key])) {
|
if (!(key in data) || !Array.isArray(data[key])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const subscription of data[key]) {
|
const isValid = validateOscSubscriptionCycle(data[key]);
|
||||||
if (typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') {
|
if (!isValid) {
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -200,8 +204,9 @@ export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
|
|||||||
if ('osc' in data) {
|
if ('osc' in data) {
|
||||||
console.log('Found OSC definition, importing...');
|
console.log('Found OSC definition, importing...');
|
||||||
|
|
||||||
|
// TODO: this can be improved by only merging known keys
|
||||||
const loadedConfig = data.osc || {};
|
const loadedConfig = data.osc || {};
|
||||||
const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions)
|
const validatedSubscriptions = validateOscSubscriptionObject(loadedConfig.subscriptions)
|
||||||
? loadedConfig.subscriptions
|
? loadedConfig.subscriptions
|
||||||
: dbModel.osc.subscriptions;
|
: dbModel.osc.subscriptions;
|
||||||
|
|
||||||
@@ -216,20 +221,63 @@ export const parseOsc = (data: { osc?: Partial<OSCSettings> }): OSCSettings => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates HTTP subscription cycle options
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export const validateHttpSubscriptionCycle = (data: HttpSubscriptionOptions[]): boolean => {
|
||||||
|
for (const subscriptionOption of data) {
|
||||||
|
const isHttp = subscriptionOption.message?.startsWith('http://');
|
||||||
|
if (typeof subscriptionOption.message !== 'string' || !isHttp || typeof subscriptionOption.enabled !== 'boolean') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and validates HTTP subscription object
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export const validateHttpSubscriptionObject = (data: HttpSubscription): boolean => {
|
||||||
|
if (!data) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const timerKeys = Object.keys(TimerLifeCycle);
|
||||||
|
// must contains all keys and be an array
|
||||||
|
for (const key of timerKeys) {
|
||||||
|
if (!(key in data) || !Array.isArray(data[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isValid = validateHttpSubscriptionCycle(data[key]);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Http portion of an entry
|
* Parse Http portion of an entry
|
||||||
* @param {object} data - data object
|
* @param {object} data - data object
|
||||||
* @param {boolean} enforce - whether to create a definition if one is missing
|
* @param {boolean} enforce - whether to create a definition if one is missing
|
||||||
* @returns {object} - event object data
|
* @returns {object} - event object data
|
||||||
*/
|
*/
|
||||||
export const parseHttp = (data, enforce) => {
|
export const parseHttp = (data: { http?: Partial<HttpSettings> }): HttpSettings => {
|
||||||
const newHttp = {};
|
|
||||||
if ('http' in data) {
|
if ('http' in data) {
|
||||||
console.log('Found HTTP definition, importing...');
|
console.log('Found HTTP definition, importing...');
|
||||||
} else if (enforce) {
|
|
||||||
/* Not yet */
|
// TODO: this can be improved by only merging known keys
|
||||||
|
const loadedConfig = data?.http || {};
|
||||||
|
const validatedSubscriptions = validateHttpSubscriptionObject(loadedConfig.subscriptions)
|
||||||
|
? loadedConfig.subscriptions
|
||||||
|
: dbModel.http.subscriptions;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut,
|
||||||
|
subscriptions: validatedSubscriptions,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return newHttp;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,4 +331,4 @@ export const parseUserFields = (data): UserFields => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ...newUserFields };
|
return { ...newUserFields };
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { sheets_v4 } from '@googleapis/sheets';
|
||||||
|
import { millisToString } from 'ontime-utils';
|
||||||
|
import { OntimeRundownEntry, ProjectData, isOntimeEvent } from 'ontime-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {number} row - The row number of the cell reference. Row 1 is row number 0.
|
||||||
|
* @param {number} column - The column number of the cell reference. A is column number 0.
|
||||||
|
* @returns {string} - Returns a cell reference as a string using A1 Notation
|
||||||
|
* @author https://www.labnol.org/convert-column-a1-notation-210601
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* getA1Notation(2, 4) returns "E3"
|
||||||
|
* getA1Notation(99, 26) returns "AA100"
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export function getA1Notation(row: number, column: number): string {
|
||||||
|
if (row < 0 || column < 0) {
|
||||||
|
throw new Error('Index can not be less than 0');
|
||||||
|
}
|
||||||
|
const a1Notation = [`${row + 1}`];
|
||||||
|
const totalAlphabets = 'Z'.charCodeAt(0) - 'A'.charCodeAt(0) + 1;
|
||||||
|
let block = column;
|
||||||
|
while (block >= 0) {
|
||||||
|
a1Notation.unshift(String.fromCharCode((block % totalAlphabets) + 'A'.charCodeAt(0)));
|
||||||
|
block = Math.floor(block / totalAlphabets) - 1;
|
||||||
|
}
|
||||||
|
return a1Notation.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description - creates updateCells request from ontime event
|
||||||
|
* @param {OntimeRundownEntry} event
|
||||||
|
* @param {number} index - index of the event
|
||||||
|
* @param {number} worksheetId
|
||||||
|
* @param {any} metadata - object with all the cell positions of the title of each attribute
|
||||||
|
* @returns {sheets_v4.Schema} - list of update requests
|
||||||
|
*/
|
||||||
|
export function cellRequestFromEvent(
|
||||||
|
event: OntimeRundownEntry,
|
||||||
|
index: number,
|
||||||
|
worksheetId: number,
|
||||||
|
metadata,
|
||||||
|
): sheets_v4.Schema$Request {
|
||||||
|
const returnRows: sheets_v4.Schema$CellData[] = [];
|
||||||
|
const tmp = Object.entries(metadata)
|
||||||
|
.filter(([_, value]) => value !== undefined)
|
||||||
|
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
|
||||||
|
|
||||||
|
const titleCol = tmp[0][1].col;
|
||||||
|
|
||||||
|
for (const [index, e] of tmp.entries()) {
|
||||||
|
if (index !== 0) {
|
||||||
|
const prevCol = tmp[index - 1][1].col;
|
||||||
|
const thisCol = e[1].col;
|
||||||
|
const diff = thisCol - prevCol;
|
||||||
|
if (diff > 1) {
|
||||||
|
const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: e[1].row, col: prevCol + 1 }]);
|
||||||
|
tmp.splice(index, 0, ...fillArr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp.forEach(([key, _]) => {
|
||||||
|
if (isOntimeEvent(event)) {
|
||||||
|
if (key === 'blank') {
|
||||||
|
returnRows.push({});
|
||||||
|
} else if (key === 'colour') {
|
||||||
|
returnRows.push({
|
||||||
|
userEnteredValue: { stringValue: event.colour },
|
||||||
|
});
|
||||||
|
} else if (typeof event[key] === 'number') {
|
||||||
|
returnRows.push({
|
||||||
|
userEnteredValue: { stringValue: millisToString(event[key], true) },
|
||||||
|
});
|
||||||
|
} else if (typeof event[key] === 'string') {
|
||||||
|
returnRows.push({
|
||||||
|
userEnteredValue: { stringValue: event[key] },
|
||||||
|
});
|
||||||
|
} else if (typeof event[key] === 'boolean') {
|
||||||
|
returnRows.push({
|
||||||
|
userEnteredValue: { stringValue: event[key] ? 'x' : '' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
returnRows.push({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
updateCells: {
|
||||||
|
start: {
|
||||||
|
sheetId: worksheetId,
|
||||||
|
rowIndex: index + tmp[0][1]['row'] + 1,
|
||||||
|
columnIndex: titleCol,
|
||||||
|
},
|
||||||
|
fields: 'userEnteredValue',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
values: returnRows,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description - creates updateCells request from ontime event
|
||||||
|
* @param {ProjectData} projectData
|
||||||
|
* @param {number} worksheetId
|
||||||
|
* @param {any} metadata - object with all the cell positions of the title of each attribute
|
||||||
|
* @returns {sheets_v4.Schema} - list of update requests
|
||||||
|
*/
|
||||||
|
export function cellRequenstFromProjectData(
|
||||||
|
projectData: ProjectData,
|
||||||
|
worksheetId: number,
|
||||||
|
metadata,
|
||||||
|
): sheets_v4.Schema$Request {
|
||||||
|
const returnRows: sheets_v4.Schema$RowData[] = [];
|
||||||
|
const tmp = Object.entries(metadata)
|
||||||
|
.filter(([_, value]) => value !== undefined)
|
||||||
|
.sort(([_a, a], [_b, b]) => a['col'] - b['col']) as [string, { col: number; row: number }][];
|
||||||
|
|
||||||
|
const minRow = Object.values(metadata).reduce(
|
||||||
|
(accumulator: number, val) => Math.min(accumulator, val['row']),
|
||||||
|
Number.MAX_VALUE,
|
||||||
|
) as number;
|
||||||
|
const minCol = tmp[0][1].col + 1;
|
||||||
|
|
||||||
|
for (const [index, e] of tmp.entries()) {
|
||||||
|
if (index != 0) {
|
||||||
|
const prevRow = tmp[index - 1][1].row;
|
||||||
|
const thisRow = e[1].row;
|
||||||
|
const diff = thisRow - prevRow;
|
||||||
|
if (diff > 1) {
|
||||||
|
const fillArr = new Array<(typeof tmp)[0]>(1).fill(['blank', { row: prevRow + 1, col: e[1].col }]);
|
||||||
|
tmp.splice(index, 0, ...fillArr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tmp.forEach(([key, _]) => {
|
||||||
|
if (key == 'blank') {
|
||||||
|
returnRows.push({});
|
||||||
|
} else {
|
||||||
|
returnRows.push({
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
userEnteredValue: { stringValue: projectData[key] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateCells: {
|
||||||
|
start: {
|
||||||
|
sheetId: worksheetId,
|
||||||
|
rowIndex: minRow,
|
||||||
|
columnIndex: minCol,
|
||||||
|
},
|
||||||
|
fields: 'userEnteredValue',
|
||||||
|
rows: returnRows,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import { sheets, sheets_v4 } from '@googleapis/sheets';
|
||||||
|
import { writeFile } from 'fs/promises';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
|
import http from 'http';
|
||||||
|
import { DatabaseModel, LogOrigin } from 'ontime-types';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { URL } from 'url';
|
||||||
|
import { logger } from '../classes/Logger.js';
|
||||||
|
import { DataProvider } from '../classes/data-provider/DataProvider.js';
|
||||||
|
import { getAppDataPath } from '../setup.js';
|
||||||
|
import { ensureDirectory } from './fileManagement.js';
|
||||||
|
import { cellRequestFromEvent, cellRequenstFromProjectData, getA1Notation } from './sheetUtils.js';
|
||||||
|
import { parseExcel } from './parser.js';
|
||||||
|
import { parseProject, parseRundown, parseUserFields } from './parserFunctions.js';
|
||||||
|
import { ExcelImportMap } from 'ontime-utils';
|
||||||
|
|
||||||
|
type ResponseOK = {
|
||||||
|
data: Partial<DatabaseModel>;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Sheet {
|
||||||
|
private static client: null | OAuth2Client = null;
|
||||||
|
private readonly scope = 'https://www.googleapis.com/auth/spreadsheets';
|
||||||
|
private readonly sheetsFolder: string;
|
||||||
|
private readonly clientSecretFile: string;
|
||||||
|
private static clientSecret = null;
|
||||||
|
private static authUrl: null | string = null;
|
||||||
|
private authServerTimeout;
|
||||||
|
|
||||||
|
private readonly requiredClientKeys = [
|
||||||
|
'client_id',
|
||||||
|
'project_id',
|
||||||
|
'auth_uri',
|
||||||
|
'token_uri',
|
||||||
|
'auth_provider_x509_cert_url',
|
||||||
|
'client_secret',
|
||||||
|
'redirect_uris',
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const appDataPath = getAppDataPath();
|
||||||
|
if (appDataPath === '') {
|
||||||
|
throw new Error('Sheet: Could not resolve sheet folser');
|
||||||
|
}
|
||||||
|
this.sheetsFolder = join(appDataPath, 'sheets');
|
||||||
|
this.clientSecretFile = join(this.sheetsFolder, 'client_secret.json');
|
||||||
|
ensureDirectory(this.sheetsFolder);
|
||||||
|
try {
|
||||||
|
const secrets = JSON.parse(readFileSync(this.clientSecretFile, 'utf-8'));
|
||||||
|
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
|
||||||
|
if (!isKeyMissing) {
|
||||||
|
Sheet.clientSecret = secrets;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* empty - it is ok thet there is no clientSecret */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 1 - saves secrets object to appdata path as client_secret.json
|
||||||
|
* @param {object} secrets
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
public async saveClientSecrets(secrets: object) {
|
||||||
|
Sheet.client = null;
|
||||||
|
Sheet.authUrl = null;
|
||||||
|
Sheet.clientSecret = null;
|
||||||
|
|
||||||
|
const isKeyMissing = this.requiredClientKeys.some((key) => !(key in secrets['installed']));
|
||||||
|
if (isKeyMissing) {
|
||||||
|
throw new Error('Client file is missing some keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(this.clientSecretFile, JSON.stringify(secrets), 'utf-8').catch((err) => {
|
||||||
|
throw new Error(`Unable to save client file to disk ${err}`);
|
||||||
|
});
|
||||||
|
Sheet.clientSecret = secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 1 - test that the saved object is pressent
|
||||||
|
*/
|
||||||
|
testClientSecret() {
|
||||||
|
return Sheet.clientSecret !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 2 - create server to interact with th OAuth2 request
|
||||||
|
* @returns {Promise<string | null>} - returns url path serve on success
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
async openAuthServer(): Promise<string | null> {
|
||||||
|
//TODO: this only works on local networks
|
||||||
|
|
||||||
|
// if the server is allready running retun it
|
||||||
|
if (Sheet.authUrl) {
|
||||||
|
clearTimeout(this.authServerTimeout);
|
||||||
|
this.authServerTimeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
Sheet.authUrl = null;
|
||||||
|
server.unref;
|
||||||
|
},
|
||||||
|
2 * 60 * 1000,
|
||||||
|
);
|
||||||
|
return Sheet.authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that Secret is valid
|
||||||
|
const keyFile = Sheet.clientSecret;
|
||||||
|
const keys = keyFile.installed || keyFile.web;
|
||||||
|
if (!keys.redirect_uris || keys.redirect_uris.length === 0) {
|
||||||
|
throw new Error('Sheet: Missing redirect URI');
|
||||||
|
}
|
||||||
|
const redirectUri = new URL(keys.redirect_uris[0]);
|
||||||
|
if (redirectUri.hostname !== 'localhost') {
|
||||||
|
throw new Error('Sheet: Invalid redirect URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
// create an oAuth client to authorize the API call
|
||||||
|
const client = new OAuth2Client({
|
||||||
|
clientId: keys.client_id,
|
||||||
|
clientSecret: keys.client_secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// start the server that will recive the codes
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const serverUrl = new URL(req.url, 'http://localhost:3000');
|
||||||
|
if (serverUrl.pathname !== redirectUri.pathname) {
|
||||||
|
res.end('Invalid callback URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const searchParams = serverUrl.searchParams;
|
||||||
|
if (searchParams.has('error')) {
|
||||||
|
res.end('Authorization rejected.');
|
||||||
|
logger.info(LogOrigin.Server, `Sheet: ${searchParams.get('error')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!searchParams.has('code')) {
|
||||||
|
res.end('No authentication code provided.');
|
||||||
|
logger.info(LogOrigin.Server, `Sheet: Cannot read authentication code`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = searchParams.get('code');
|
||||||
|
const { tokens } = await client.getToken({
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri.toString(),
|
||||||
|
});
|
||||||
|
client.credentials = tokens;
|
||||||
|
Sheet.client = client;
|
||||||
|
res.end('Authentication successful! Please close this tab and return to OnTime.');
|
||||||
|
logger.info(LogOrigin.Server, `Sheet: Authentication successful`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(LogOrigin.Server, `Sheet: ${e}`);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let listenPort = 3000;
|
||||||
|
if (keyFile.installed) {
|
||||||
|
// Use emphemeral port if not a web client
|
||||||
|
listenPort = 0;
|
||||||
|
} else if (redirectUri.port !== '') {
|
||||||
|
listenPort = Number(redirectUri.port);
|
||||||
|
}
|
||||||
|
//TODO: the server might not start correctly
|
||||||
|
server.listen(listenPort);
|
||||||
|
const address = server.address();
|
||||||
|
if (typeof address !== 'string') {
|
||||||
|
redirectUri.port = String(address.port);
|
||||||
|
}
|
||||||
|
// open the browser to the authorize url to start the workflow
|
||||||
|
const authorizeUrl = client.generateAuthUrl({
|
||||||
|
redirect_uri: redirectUri.toString(),
|
||||||
|
access_type: 'offline',
|
||||||
|
scope: this.scope,
|
||||||
|
});
|
||||||
|
Sheet.authUrl = authorizeUrl;
|
||||||
|
this.authServerTimeout = setTimeout(
|
||||||
|
() => {
|
||||||
|
Sheet.authUrl = null;
|
||||||
|
server.unref();
|
||||||
|
},
|
||||||
|
2 * 60 * 1000,
|
||||||
|
);
|
||||||
|
return authorizeUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 2 - test that the reciveed OAuth2 is still valid
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
async testAuthentication() {
|
||||||
|
if (Sheet.client) {
|
||||||
|
const ref = await Sheet.client.refreshAccessToken();
|
||||||
|
if (ref.credentials.expiry_date > 10000) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error('Unable to use access token');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Unable to authenticate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 3 - test the given sheet id
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
async testSheetId(id: string) {
|
||||||
|
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
|
||||||
|
spreadsheetId: id,
|
||||||
|
includeGridData: false,
|
||||||
|
});
|
||||||
|
if (spreadsheets.status != 200) {
|
||||||
|
throw new Error(spreadsheets.statusText);
|
||||||
|
}
|
||||||
|
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 4 - test the given worksheet
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
async testWorksheet(id: string, worksheet: string) {
|
||||||
|
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
|
||||||
|
spreadsheetId: id,
|
||||||
|
includeGridData: false,
|
||||||
|
});
|
||||||
|
if (spreadsheets.status != 200) {
|
||||||
|
throw new Error(spreadsheets.statusText);
|
||||||
|
}
|
||||||
|
const worksheetExist = spreadsheets.data.sheets.find((i) => i.properties.title === worksheet);
|
||||||
|
if (!worksheetExist) {
|
||||||
|
throw new Error('Unable to find worksheet');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test existence of sheet and worksheet
|
||||||
|
* @param {string} sheetId - https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
|
||||||
|
* @param {string} worksheet - the name of the worksheet containing ontime data
|
||||||
|
* @returns {Promise<{worksheetId: number, range: string}>} - id of worksheet and rage of worksheet
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
private async exist(sheetId: string, worksheet: string): Promise<{ worksheetId: number; range: string }> {
|
||||||
|
const spreadsheets = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.get({
|
||||||
|
spreadsheetId: sheetId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (spreadsheets.status === 200) {
|
||||||
|
const ourWorksheetData = spreadsheets.data.sheets.find((n) => n.properties.title == worksheet);
|
||||||
|
if (ourWorksheetData !== undefined) {
|
||||||
|
const endCell = getA1Notation(
|
||||||
|
ourWorksheetData.properties.gridProperties.rowCount,
|
||||||
|
ourWorksheetData.properties.gridProperties.columnCount,
|
||||||
|
);
|
||||||
|
return { worksheetId: ourWorksheetData.properties.sheetId, range: `${worksheet}!A1:${endCell}` };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Uable to open spreadsheets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description SETP 5 - Upload the rundown to sheet
|
||||||
|
* @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
|
||||||
|
* @param {ExcelImportMap} options
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
public async push(id: string, options: ExcelImportMap) {
|
||||||
|
const { worksheetId, range } = await this.exist(id, options.worksheet);
|
||||||
|
|
||||||
|
const readResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
|
||||||
|
spreadsheetId: id,
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range: range,
|
||||||
|
});
|
||||||
|
if (readResponse.status === 200) {
|
||||||
|
const { rundownMetadata, projectMetadata } = parseExcel(readResponse.data.values, options);
|
||||||
|
const rundown = DataProvider.getRundown();
|
||||||
|
const projectData = DataProvider.getProjectData();
|
||||||
|
const titleRow = Object.values(rundownMetadata)[0]['row'];
|
||||||
|
|
||||||
|
const updateRundown = Array<sheets_v4.Schema$Request>();
|
||||||
|
|
||||||
|
// we can't delete the last unflozzen row so we create an empty one
|
||||||
|
updateRundown.push({
|
||||||
|
insertDimension: {
|
||||||
|
inheritFromBefore: false,
|
||||||
|
range: {
|
||||||
|
dimension: 'ROWS',
|
||||||
|
startIndex: titleRow + 1,
|
||||||
|
endIndex: titleRow + 2,
|
||||||
|
sheetId: worksheetId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
//and delete the rest
|
||||||
|
updateRundown.push({
|
||||||
|
deleteDimension: { range: { dimension: 'ROWS', startIndex: titleRow + 2, sheetId: worksheetId } },
|
||||||
|
});
|
||||||
|
// insert the lenght of the rundown
|
||||||
|
updateRundown.push({
|
||||||
|
insertDimension: {
|
||||||
|
inheritFromBefore: false,
|
||||||
|
range: {
|
||||||
|
dimension: 'ROWS',
|
||||||
|
startIndex: titleRow + 1,
|
||||||
|
endIndex: titleRow + rundown.length,
|
||||||
|
sheetId: worksheetId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
//update the corresponding row with event data
|
||||||
|
rundown.forEach((entry, index) =>
|
||||||
|
updateRundown.push(cellRequestFromEvent(entry, index, worksheetId, rundownMetadata)),
|
||||||
|
);
|
||||||
|
|
||||||
|
//update project data
|
||||||
|
updateRundown.push(cellRequenstFromProjectData(projectData, worksheetId, projectMetadata));
|
||||||
|
|
||||||
|
const writeResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.batchUpdate({
|
||||||
|
spreadsheetId: id,
|
||||||
|
requestBody: {
|
||||||
|
includeSpreadsheetInResponse: false,
|
||||||
|
responseRanges: [range],
|
||||||
|
requests: updateRundown,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (writeResponse.status === 200) {
|
||||||
|
logger.info(LogOrigin.Server, `Sheet: write: ${writeResponse.statusText}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Sheet: write failed: ${writeResponse.statusText}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Sheet: read failed: ${readResponse.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description STEP 5 - Downpload the rundown from sheet
|
||||||
|
* @param {string} id - id of the sheet https://docs.google.com/spreadsheets/d/[[spreadsheetId]]/edit#gid=0
|
||||||
|
* @param {ExcelImportMap} options
|
||||||
|
* @returns {Promise<Partial<ResponseOK>>}
|
||||||
|
* @throws
|
||||||
|
*/
|
||||||
|
public async pull(id: string, options: ExcelImportMap): Promise<Partial<ResponseOK>> {
|
||||||
|
const { range } = await this.exist(id, options.worksheet);
|
||||||
|
|
||||||
|
const res: Partial<ResponseOK> = {};
|
||||||
|
|
||||||
|
const googleResponse = await sheets({ version: 'v4', auth: Sheet.client }).spreadsheets.values.get({
|
||||||
|
spreadsheetId: id,
|
||||||
|
valueRenderOption: 'FORMATTED_VALUE',
|
||||||
|
majorDimension: 'ROWS',
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (googleResponse.status === 200) {
|
||||||
|
res.data = {};
|
||||||
|
const dataFromSheet = parseExcel(googleResponse.data.values, options);
|
||||||
|
res.data.rundown = parseRundown(dataFromSheet);
|
||||||
|
if (res.data.rundown.length < 1) {
|
||||||
|
throw new Error(`Sheet: Could not find data to import in the worksheet`);
|
||||||
|
}
|
||||||
|
res.data.project = parseProject(dataFromSheet);
|
||||||
|
res.data.userFields = parseUserFields(dataFromSheet);
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Sheet: read failed: ${googleResponse.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sheet = new Sheet();
|
||||||
@@ -259,6 +259,17 @@
|
|||||||
"targetIP": "127.0.0.1",
|
"targetIP": "127.0.0.1",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"http": {
|
||||||
|
"enabledOut": false,
|
||||||
|
"subscriptions": {
|
||||||
|
"onLoad": [],
|
||||||
|
"onStart": [],
|
||||||
|
"onPause": [],
|
||||||
|
"onStop": [],
|
||||||
|
"onUpdate": [],
|
||||||
|
"onFinish": []
|
||||||
|
}
|
||||||
|
},
|
||||||
"aliases": [
|
"aliases": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
|||||||
+18
-7
@@ -29,8 +29,8 @@
|
|||||||
"user9": "",
|
"user9": "",
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"id": "aa42f",
|
"cue": "1",
|
||||||
"cue": "1"
|
"id": "aa42f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "title 2",
|
"title": "title 2",
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
"user9": "",
|
"user9": "",
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"id": "d71bc",
|
"cue": "2",
|
||||||
"cue": "2"
|
"id": "d71bc"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"title": "title 3",
|
"title": "title 3",
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
"user9": "",
|
"user9": "",
|
||||||
"type": "event",
|
"type": "event",
|
||||||
"revision": 0,
|
"revision": 0,
|
||||||
"id": "da5b4",
|
"cue": "3",
|
||||||
"cue": "3"
|
"id": "da5b4"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"project": {
|
"project": {
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"app": "ontime",
|
"app": "ontime",
|
||||||
"version": "2.0.0",
|
"version": "2.24.8",
|
||||||
"serverPort": 4001,
|
"serverPort": 4001,
|
||||||
"editorKey": null,
|
"editorKey": null,
|
||||||
"operatorKey": null,
|
"operatorKey": null,
|
||||||
@@ -148,5 +148,16 @@
|
|||||||
"onUpdate": [],
|
"onUpdate": [],
|
||||||
"onFinish": []
|
"onFinish": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"enabledOut": true,
|
||||||
|
"subscriptions": {
|
||||||
|
"onLoad": [],
|
||||||
|
"onStart": [],
|
||||||
|
"onPause": [],
|
||||||
|
"onStop": [],
|
||||||
|
"onUpdate": [],
|
||||||
|
"onFinish": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,5 +468,16 @@
|
|||||||
],
|
],
|
||||||
"onFinish": []
|
"onFinish": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"http": {
|
||||||
|
"enabledOut": true,
|
||||||
|
"subscriptions": {
|
||||||
|
"onLoad": [],
|
||||||
|
"onStart": [],
|
||||||
|
"onPause": [],
|
||||||
|
"onStop": [],
|
||||||
|
"onUpdate": [],
|
||||||
|
"onFinish": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
services:
|
services:
|
||||||
ontime:
|
ontime:
|
||||||
container_name: ontime
|
container_name: ontime
|
||||||
image: getontime/ontime:beta_v2
|
image: getontime/ontime:latest
|
||||||
ports:
|
ports:
|
||||||
- "4001:4001/tcp"
|
- "4001:4001/tcp"
|
||||||
- "8888:8888/udp"
|
- "8888:8888/udp"
|
||||||
|
|||||||
+4
-2
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "2.21.3",
|
"version": "2.28.11",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"lighdev",
|
"lightdev",
|
||||||
"ontime",
|
"ontime",
|
||||||
"timer",
|
"timer",
|
||||||
"rundown"
|
"rundown"
|
||||||
@@ -30,6 +30,8 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"build:local": "turbo run build:local",
|
"build:local": "turbo run build:local",
|
||||||
"build:electron": "turbo run build:electron",
|
"build:electron": "turbo run build:electron",
|
||||||
|
"build:docker": "turbo run build:docker",
|
||||||
|
"build:localdocker": "turbo run build:localdocker",
|
||||||
"dist-win": "turbo run dist-win",
|
"dist-win": "turbo run dist-win",
|
||||||
"dist-mac": "turbo run dist-mac",
|
"dist-mac": "turbo run dist-mac",
|
||||||
"dist-linux": "turbo run dist-linux",
|
"dist-linux": "turbo run dist-linux",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { OSCSettings } from './core/OscSettings.type.js';
|
|||||||
import { Settings } from './core/Settings.type.js';
|
import { Settings } from './core/Settings.type.js';
|
||||||
import { UserFields } from './core/UserFields.type.js';
|
import { UserFields } from './core/UserFields.type.js';
|
||||||
import { ViewSettings } from './core/Views.type.js';
|
import { ViewSettings } from './core/Views.type.js';
|
||||||
|
import { HttpSettings } from '../index.js';
|
||||||
|
|
||||||
export type DatabaseModel = {
|
export type DatabaseModel = {
|
||||||
rundown: OntimeRundown;
|
rundown: OntimeRundown;
|
||||||
@@ -14,4 +15,5 @@ export type DatabaseModel = {
|
|||||||
aliases: Alias[];
|
aliases: Alias[];
|
||||||
userFields: UserFields;
|
userFields: UserFields;
|
||||||
osc: OSCSettings;
|
osc: OSCSettings;
|
||||||
|
http: HttpSettings;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Subscription } from './Subscription.type.js';
|
||||||
|
|
||||||
|
export type HttpSubscriptionOptions = { message: string; enabled: boolean };
|
||||||
|
export type HttpSubscription = Subscription<HttpSubscriptionOptions>;
|
||||||
|
|
||||||
|
export interface HttpSettings {
|
||||||
|
enabledOut: boolean;
|
||||||
|
subscriptions: HttpSubscription;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user