Compare commits

...

58 Commits

Author SHA1 Message Date
Alex Christoffer Rasmussen 48ebd31c54 bump version to 3.1.1 (#1045) 2024-06-04 15:57:52 +02:00
Carlos Valente 6d1a911ab8 refactor: rename negative to overtime 2024-06-04 15:51:00 +02:00
Carlos Valente ee1753ef29 fix: account for pending timers 2024-06-04 15:51:00 +02:00
Carlos Valente 147e5f5a27 chore: remove unused dependencies 2024-06-03 22:38:57 +02:00
Carlos Valente a02b3faacc chore: bump version to 3.1.0 2024-06-03 22:38:57 +02:00
Alex Christoffer Rasmussen a3b21072eb keep the index row as a colour chip (#1039) 2024-06-03 22:38:22 +02:00
Carlos Valente 2076080c5f refactor: supress modifiers in count-up timers 2024-06-03 22:36:05 +02:00
Alex Christoffer Rasmussen 8b3abe9d61 fix: insert above shortcut (#1020)
refactor: unify setCursor and useEventSelection
2024-06-03 21:34:00 +02:00
Carlos Valente c2039866b4 refactor: fix missed first update (#1031) 2024-06-03 20:27:51 +02:00
Carlos Valente 73e4a15718 style: add label to auxiliary timer 2024-06-03 20:26:25 +02:00
asharonbaltazar c4359af2a0 feat:toggle index column (#1037)
* feat:add `showIndexColumn` boolean property to `CuesheetSettings`

* feat:use `showIndexColumn` to hide column index th/tr

* feat:pass `showIndexColumn` to components

* fix:update column headers naming
2024-06-03 14:21:11 -04:00
Carlos Valente f75f45b19a refactor: simplify client logic on timer phases 2024-06-03 20:18:19 +02:00
Carlos Valente fd2eee32aa refactor: declare timer phase 2024-06-03 20:18:19 +02:00
Carlos Valente a53d354b16 refactor: continue roll 2024-06-03 20:17:41 +02:00
Alex Christoffer Rasmussen ec55236eb2 fix: osc new project (#1032) 2024-06-02 18:01:36 +02:00
Alex Christoffer Rasmussen 3895b37572 Allow time change from api (#1009)
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-02 15:21:09 +02:00
Julien Recurt d8626ff324 Add option to display the full time (HH:mm:ss) in the timer view (#988)
* Add option to display the full time (HH:mm:ss) in the timer view

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2024-06-01 13:39:15 +02:00
arc-alex 6c3870c15b catch getWorksheetNames 2024-05-31 22:17:17 +02:00
Carlos Valente 4fadb23b07 refactor: timer overrides do not affect minimal 2024-05-31 22:17:03 +02:00
Carlos Valente 199ed1617f fix: menu link to operator 2024-05-31 18:57:45 +02:00
Carlos Valente 2d618e0657 refactor: remove double import 2024-05-30 22:41:50 +02:00
Carlos Valente f928c1dc95 refactor: avoid unnecessary computer property 2024-05-30 22:41:50 +02:00
jwetzell 8d526c7d31 Merge pull request #1019 from cpvalente/cleanup-server-setup
remove redundant path evaluation
2024-05-30 07:57:23 -05:00
Carlos Valente 686c6108bf refactor: improve payload type for OSC (#954)
---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2024-05-30 13:02:22 +02:00
Carlos Valente ea3d33ca93 fix: correct key on shortcuts 2024-05-30 12:15:57 +02:00
jwetzell 6bf94ff645 remove redundant path evaluation 2024-05-29 16:25:15 -05:00
Carlos Valente 930cd71ffd chore: upgrade electron 2024-05-29 08:33:44 +02:00
Carlos Valente 45cf1cf199 chore: bump version to 3.0.4 2024-05-28 22:49:29 +02:00
Carlos Valente 354f464263 chore: user feedback 2024-05-28 22:49:29 +02:00
Carlos Valente c46a00febc chore: expand download links 2024-05-28 22:49:29 +02:00
Carlos Valente e991e151db refactor: improve dnd activation strategy 2024-05-28 22:45:10 +02:00
Carlos Valente b5993b91e1 fix: column reordering
refactor: migrate local storage hook
2024-05-28 22:45:10 +02:00
arc-alex 4beac19c4a Clear upload folder on startup 2024-05-28 22:44:55 +02:00
Carlos Valente be852f4546 refactor: allow hiding sidebar 2024-05-27 15:52:02 +02:00
Carlos Valente 84e224dd71 refactor: unify time parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente 7cbb2112ed refactor: simplify parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente ce910c9cc6 refactor: extract parseUserTime 2024-05-27 15:51:45 +02:00
Alex Christoffer Rasmussen 9d04954d87 fix change api isPublic (#1008) 2024-05-27 13:01:10 +02:00
Alex Christoffer Rasmussen 345ea6722e add notes to secondaryOptions in timer view (#1004) 2024-05-27 12:22:01 +02:00
Alex Christoffer Rasmussen f43221cf7a don't notify when aux timer is paused (#1003) 2024-05-27 11:51:07 +02:00
Carlos Valente a09aa922bb refactor: delete several events 2024-05-26 21:36:49 +02:00
Carlos Valente 16f31a07b7 chore: upgrade dependencies 2024-05-26 21:36:49 +02:00
Carlos Valente 77d111650a refactor: reduce scope of context 2024-05-26 21:36:49 +02:00
jwetzell 72891a69f4 Merge pull request #998 from cpvalente/fix/smart-time-parsing
address some issues in am/pm time parsing
2024-05-26 11:07:43 -05:00
Carlos Valente b8d5a30caf style: input text alignment 2024-05-25 15:19:01 +02:00
jwetzell 9cbd4c085b slim back tests even further and list out 2024-05-24 06:51:51 -05:00
jwetzell afe0323384 use let not var 2024-05-24 06:12:46 -05:00
jwetzell 1907abc512 pare back am/pm tests 2024-05-24 05:53:07 -05:00
jwetzell 834aafe06d remove need for hardcoded 12am test 2024-05-23 20:29:05 -05:00
jwetzell 98418014d3 lowercase time string before beginning to parse 2024-05-23 20:16:41 -05:00
jwetzell 3d13e784ea fix parsing am/pm times 2024-05-23 19:45:12 -05:00
jwetzell e397500d07 dynamically build am/pm tests 2024-05-23 19:17:36 -05:00
jwetzell c7c0241859 add new am/pm test cases for dateConfig util 2024-05-23 17:39:42 -05:00
Carlos Valente 71a7fe96d1 style: fix input overflow 2024-05-23 22:13:42 +02:00
jwetzell d0f958c13d Merge pull request #990 from cpvalente/fix/docker-build
fix docker multi-architecture build
2024-05-23 08:15:07 -05:00
jwetzell 7ee98fb062 remove node setup and build from docker action 2024-05-21 16:06:28 -05:00
jwetzell c2b66a4215 rework dockerfile to remove need for turbo 2024-05-21 16:06:15 -05:00
jwetzell 1d0ee10c00 remove build:docker from turbo 2024-05-21 16:05:49 -05:00
101 changed files with 1802 additions and 1108 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

-18
View File
@@ -17,24 +17,6 @@ jobs:
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
+3 -5
View File
@@ -1,13 +1,11 @@
FROM node:18.18-alpine AS base
FROM node:18.18-alpine AS builder
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS builder
COPY . /app
WORKDIR /app
RUN pnpm install --frozen-lockfile \
&& pnpm turbo build:docker
RUN pnpm --filter=ontime-ui --filter=ontime-server --filter=ontime-utils install --config.dedupe-peer-dependents=false --frozen-lockfile
RUN pnpm --filter=ontime-ui --filter=ontime-server run build:docker
FROM node:18.18-alpine
+25 -21
View File
@@ -4,19 +4,22 @@
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<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-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>
</div>
<br />
## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
@@ -24,6 +27,7 @@ Ontime is a browser-based application that manages event rundowns, scheduling, a
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
Ontime is made by entertainment and broadcast engineers and used by
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
@@ -99,16 +103,16 @@ More documentation is available [in our docs](https://docs.getontime.no)
## Main features
* [x] Distribute data over network and render it in the browser
* [x] Collaborative
* [x] Extendable
* [x] Send messages to different screen types
* [x] Differentiate between backstage and public data
* [x] Workflow for managing delays
* [x] Rich protocol integrations for Control and Feedback
* [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
* [x] Multi-platform (available on Windows, MacOS and Linux)
* [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.3",
"version": "3.1.1",
"private": true,
"type": "module",
"dependencies": {
@@ -30,7 +30,7 @@
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"web-vitals": "^3.1.1",
"zustand": "^4.5.0"
"zustand": "^4.5.2"
},
"scripts": {
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
+1 -4
View File
@@ -3,7 +3,6 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
@@ -25,9 +24,7 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<ContextMenu>
<AppRouter />
</ContextMenu>
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
+2 -2
View File
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
@@ -75,6 +75,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
@@ -1,9 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
@@ -40,7 +39,7 @@ export default function DelayInput(props: DelayInputProps) {
}
const isNegative = newValue.startsWith('-');
let newMillis = forgivingStringToMillis(newValue);
let newMillis = parseUserTime(newValue);
if (isNegative) {
newMillis = newMillis * -1;
@@ -1,23 +1,21 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -58,7 +56,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
submitHandler(name, newValue);
}
const valueInMillis = forgivingStringToMillis(newValue);
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
@@ -117,15 +115,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
className={className}
fontSize='1rem'
type='text'
placeholder={placeholder}
@@ -136,7 +132,10 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off'
textAlign={align}
/>
);
}
@@ -6,7 +6,3 @@ $input-delayed-border-color: $ontime-delay-text;
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
align='left'
disabled={disabled}
/>
{children}
@@ -30,6 +30,14 @@ const hideTimerSeconds: ParamField = {
defaultValue: false,
};
const showLeadingZeros: ParamField = {
id: 'showLeadingZeros',
title: 'Show leading zeros in timer',
description: 'Whether to show leading zeros in the running timer',
type: 'boolean',
defaultValue: false,
};
export const getClockOptions = (timeFormat: string): ParamField[] => [
getTimeOption(timeFormat),
{
@@ -103,10 +111,11 @@ export const getClockOptions = (timeFormat: string): ParamField[] => [
];
export const getTimerOptions = (timeFormat: string, customFields: CustomFields): ParamField[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields);
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
getTimeOption(timeFormat),
hideTimerSeconds,
showLeadingZeros,
{
id: 'hideClock',
title: 'Hide Time Now',
@@ -230,13 +239,6 @@ export const MINIMAL_TIMER_OPTIONS: ParamField[] = [
type: 'boolean',
defaultValue: false,
},
{
id: 'hidemessages',
title: 'Hide Message Overlay',
description: 'Whether to hide the overlay from showing timer screen messages',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
+13 -11
View File
@@ -6,6 +6,7 @@ import {
getLinkedTimes,
getPreviousEventNormal,
MILLIS_PER_SECOND,
parseUserTime,
reorderArray,
swapEventData,
} from 'ontime-utils';
@@ -25,7 +26,6 @@ import {
} from '../api/rundown';
import { logAxiosError } from '../api/utils';
import { useEditorSettings } from '../stores/editorSettings';
import { forgivingStringToMillis } from '../utils/dateConfig';
/**
* @description Set of utilities for events //TODO: should this be called useEntryAction and so on
@@ -94,15 +94,15 @@ export const useEventAction = () => {
}
if (newEvent.duration === undefined && newEvent.timeEnd === undefined) {
newEvent.duration = forgivingStringToMillis(defaultDuration);
newEvent.duration = parseUserTime(defaultDuration);
}
if (newEvent.timeDanger === undefined) {
newEvent.timeDanger = forgivingStringToMillis(defaultDangerTime);
newEvent.timeDanger = parseUserTime(defaultDangerTime);
}
if (newEvent.timeWarning === undefined) {
newEvent.timeWarning = forgivingStringToMillis(defaultWarnTime);
newEvent.timeWarning = parseUserTime(defaultWarnTime);
}
}
@@ -217,9 +217,9 @@ export const useEventAction = () => {
} else if (value.startsWith('+') || value.startsWith('p+') || value.startsWith('p +')) {
// TODO: is this logic solid?
const remainingString = value.substring(1);
newValMillis = getPreviousEnd() + forgivingStringToMillis(remainingString);
newValMillis = getPreviousEnd() + parseUserTime(remainingString);
} else {
newValMillis = forgivingStringToMillis(value);
newValMillis = parseUserTime(value);
}
// dont allow timer values over 23:59:59
@@ -337,7 +337,7 @@ export const useEventAction = () => {
const _deleteEventMutation = useMutation({
mutationFn: requestDelete,
// we optimistically update here
onMutate: async (eventId) => {
onMutate: async (eventIds: string[]) => {
// cancel ongoing queries
await queryClient.cancelQueries({ queryKey: RUNDOWN });
@@ -346,9 +346,11 @@ export const useEventAction = () => {
if (previousData) {
// optimistically update object
const newOrder = previousData.order.filter((id) => id !== eventId);
const newOrder = previousData.order.filter((id) => !eventIds.includes(id));
const newRundown = { ...previousData.rundown };
delete newRundown[eventId];
for (const eventId of eventIds) {
delete newRundown[eventId];
}
queryClient.setQueryData(RUNDOWN, {
order: newOrder,
@@ -377,9 +379,9 @@ export const useEventAction = () => {
* Deletes an event form the list
*/
const deleteEvent = useCallback(
async (eventId: string) => {
async (eventIds: string[]) => {
try {
await _deleteEventMutation.mutateAsync(eventId);
await _deleteEventMutation.mutateAsync(eventIds);
} catch (error) {
logAxiosError('Error deleting event', error);
}
@@ -1,46 +0,0 @@
import { useSyncExternalStore } from 'react';
const STORAGE_EVENT = 'ontime-storage';
function getSnapshot(key: string): string | null {
try {
return window.localStorage.getItem(`ontime-${key}`);
} catch {
return null;
}
}
function getParsedJson<T>(localStorageValue: string | null, initialValue: T): T {
try {
return localStorageValue ? JSON.parse(localStorageValue) : initialValue;
} catch {
return initialValue;
}
}
export const useLocalStorage = <T>(key: string, initialValue: T) => {
const localStorageValue = useSyncExternalStore(subscribe, () => getSnapshot(key));
const parsedLocalStorageValue = getParsedJson(localStorageValue, initialValue);
/**
* @description Set value to local storage
* @param value
*/
const setLocalStorageValue = (value: T | ((val: T) => T)) => {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(parsedLocalStorageValue) : value;
localStorage.setItem(`ontime-${key}`, JSON.stringify(valueToStore));
window.dispatchEvent(new StorageEvent(STORAGE_EVENT));
};
return [parsedLocalStorageValue, setLocalStorageValue] as const;
};
function subscribe(callback: () => void) {
window.addEventListener(STORAGE_EVENT, callback);
return () => {
window.removeEventListener(STORAGE_EVENT, callback);
};
}
@@ -1,4 +1,4 @@
import { MaybeNumber, Playback, TimerType } from 'ontime-types';
import { MaybeNumber, Playback, TimerPhase, TimerType } from 'ontime-types';
// first set extends TimerState
export type ViewExtendedTimer = {
@@ -8,12 +8,11 @@ export type ViewExtendedTimer = {
elapsed: MaybeNumber;
expectedFinish: MaybeNumber;
finishedAt: MaybeNumber;
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber;
startedAt: MaybeNumber;
clock: number;
timeDanger: MaybeNumber;
timeWarning: MaybeNumber;
timerType: TimerType;
};
@@ -19,13 +19,10 @@ function persistModeToSession(mode: AppMode) {
type AppModeStore = {
mode: AppMode;
setMode: (mode: AppMode) => void;
cursor: string | null;
setCursor: (cursor: string | null) => void;
};
export const useAppMode = create<AppModeStore>()((set) => ({
mode: getModeFromSession(),
cursor: null,
setMode: (mode: AppMode) => {
persistModeToSession(mode);
@@ -33,5 +30,4 @@ export const useAppMode = create<AppModeStore>()((set) => ({
return { mode };
});
},
setCursor: (cursor: string | null) => set({ cursor }),
}));
+2 -1
View File
@@ -1,5 +1,5 @@
import isEqual from 'react-fast-compare';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { Playback, RuntimeStore, SimpleDirection, SimplePlayback, TimerPhase } from 'ontime-types';
import { createWithEqualityFn, useStoreWithEqualityFn } from 'zustand/traditional';
export const runtimeStorePlaceholder: RuntimeStore = {
@@ -11,6 +11,7 @@ export const runtimeStorePlaceholder: RuntimeStore = {
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
@@ -1,292 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { forgivingStringToMillis, millisToDelayString } from '../dateConfig';
describe('test forgivingStringToMillis()', () => {
describe('function handles time with no separators', () => {
const testData = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: MILLIS_PER_MINUTE },
{ value: '-1', expect: MILLIS_PER_MINUTE },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: MILLIS_PER_SECOND },
{ value: '0h1m0s', expect: MILLIS_PER_MINUTE },
{ value: '1h0m0s', expect: MILLIS_PER_HOUR },
{ value: '23h0m0s', expect: 23 * MILLIS_PER_HOUR },
{ value: '12h12m12s', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '12H12M12S', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '2m', expect: 2 * MILLIS_PER_MINUTE },
{ value: '1h5s', expect: MILLIS_PER_HOUR + 5 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.1.1', expect: MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.1.1', expect: 12 * MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.1', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.40', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + 40 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('#33 separators are parsed according to doc examples ', () => {
const ts = [
{ value: '0.1', expect: MILLIS_PER_MINUTE },
{ value: '0 1', expect: MILLIS_PER_MINUTE },
{ value: '0:1', expect: MILLIS_PER_MINUTE },
{ value: '0,1', expect: MILLIS_PER_MINUTE },
{ value: '2.2.2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2 2 2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2:2:2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '10:', expect: 10 * MILLIS_PER_HOUR },
{ value: ':10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '120', expect: MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE },
{ value: '90m', expect: 90 * MILLIS_PER_MINUTE },
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.2.3', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '1h3s', expect: MILLIS_PER_HOUR + 3 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '10h', expect: 10 * MILLIS_PER_HOUR },
{ value: '10m', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10s', expect: 10 * MILLIS_PER_SECOND },
{ value: '120h', expect: 120 * MILLIS_PER_HOUR },
{ value: '120m', expect: 120 * MILLIS_PER_MINUTE },
{ value: '120s', expect: 120 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles am/pm', () => {
const ampm = [
{ value: '9:10:11am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10:11p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 11 * MILLIS_PER_SECOND },
{ value: '9:10am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10a', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10p', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9am', expect: 9 * MILLIS_PER_HOUR },
{ value: '9a', expect: 9 * MILLIS_PER_HOUR },
{ value: '9pm', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '9p', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '12am', expect: 0 },
{ value: '12pm', expect: 12 * MILLIS_PER_HOUR },
];
for (const s of ampm) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '12', expect: 12 * MILLIS_PER_MINUTE }, // 00:12:00
{ value: '123', expect: MILLIS_PER_HOUR + 23 * MILLIS_PER_MINUTE }, // 01:23:00
{ value: '1234', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE }, // 12:34:00
{ value: '12345', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 5 * MILLIS_PER_SECOND }, // 12:34:05
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND }, // 12:34:56
];
for (const s of testCases) {
it(`handles basic strings digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const sixDigits = [
{ value: '000000', expect: 0 }, // 00:00:00
{ value: '000001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '000100', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230000', expect: MILLIS_PER_HOUR * 23 }, // 23:00:00
{ value: '121212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND }, // 12:12:12
];
for (const s of sixDigits) {
it(`handles string with 6 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '00010', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '00100', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '01000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '10000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '23000', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '12121', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 1 * MILLIS_PER_SECOND }, // 12:12:01
];
for (const s of fiveDigits) {
it(`handles string with 5 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '0010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '0100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '1000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '2300', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '1212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE }, // 12:12:00
];
for (const s of fourDigits) {
it(`handles string with 4 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230', expect: 2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE }, // 02:30:00
{ value: '121', expect: MILLIS_PER_HOUR + 21 * MILLIS_PER_MINUTE }, // 01:21:00
];
for (const s of threeDigits) {
it(`handles string with 3 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '10', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '23', expect: 23 * MILLIS_PER_MINUTE }, // 00:23:00
];
for (const s of twoDigits) {
it(`handles string with 2 digits: ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
const singleDigit = [...Array(10).keys()];
for (const s of singleDigit) {
it(`handles string with a single digits ${s}`, () => {
expect(forgivingStringToMillis(`${s}`)).toBe(s * 1000 * 60);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '99', expect: 99 * MILLIS_PER_MINUTE },
{ value: '1.39.0', expect: 99 * MILLIS_PER_MINUTE },
// seconds overflow
{ value: '0.0.120', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.2.0', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.0.99', expect: 99 * MILLIS_PER_SECOND },
{ value: '0.1.39', expect: 99 * MILLIS_PER_SECOND },
// hours overflow
{ value: '25.0.0', expect: 25 * MILLIS_PER_HOUR },
// hours overflow
{ value: '50.0.0', expect: 50 * MILLIS_PER_HOUR },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND }, // 01:02:03
{ value: '2,10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2.10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2 10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof forgivingStringToMillis(s.value)).toBe('number');
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.70', expect: MILLIS_PER_HOUR + 70 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * MILLIS_PER_MINUTE },
{ value: '0.99', expect: 99 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
});
}
});
});
});
import { millisToDelayString } from '../dateConfig';
describe('millisToDelayString()', () => {
it('returns null for null values', () => {
+4 -153
View File
@@ -1,160 +1,11 @@
import { MaybeNumber } from 'ontime-types';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { formatFromMillis, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
/**
* @description safe parse string to int
* @param {string} valueAsString
* @return {number}
* Parses a value in millis to a string which encodes a delay
* @param millis
* @param format
*/
const parse = (valueAsString: string): number => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
/**
* @description Utility function to check if a string contain am/pm indicators
* @param {string} value
*/
function checkAmPm(value: string) {
let isPM = false;
let isAM = false;
if (value.toLowerCase().includes('pm')) {
isPM = true;
value = value.replace(/pm/i, '');
} else if (value.toLowerCase().includes('p')) {
isPM = true;
value = value.replace(/p/i, '');
}
// we need to remove am, but it doesn't actually change anything
if (value.toLowerCase().includes('am')) {
isAM = true;
value = value.replace(/am/i, '');
} else if (value.toLowerCase().includes('a')) {
isAM = true;
value = value.replace(/a/i, '');
}
return { isAM, isPM, value };
}
/**
* @description Utility function to check if a string contain h / m / s indicators
* @param {string} value
*/
function checkMatchers(value: string) {
const hoursMatch = /(\d+)h/i.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/i.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/i.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return (
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
);
}
return { hoursMatchValue };
}
/**
* @description Utility function to infer separators from a whole string
* @param {string} value
* @param {boolean} isAM
* @param {boolean} isPM
*/
function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
const length = value.length;
let inferredMillis = 0;
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * MILLIS_PER_HOUR;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 2) {
if (isPM || isAM) {
inferredMillis = parse(value) * inferredMillis;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = 12;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 3) {
inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
} else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
} else if (length >= 5) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
}
return { inferredMillis, addAM };
}
/**
* @description Parses a time string to millis, auto-filling to the left
* @param {string} value - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value: string): number => {
if (value === '12am') {
return 0;
}
const { isAM, isPM, value: parsingValue } = checkAmPm(value);
const maybeMillisFromMatchers = checkMatchers(parsingValue);
if (typeof maybeMillisFromMatchers === 'number') {
return maybeMillisFromMatchers;
}
let { hoursMatchValue } = maybeMillisFromMatchers;
let millis = 0;
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = parsingValue.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
millis = inferredMillis;
hoursMatchValue = addAM;
}
if (first != null && second != null && third == null) {
// if string has two sections, treat as [hours] [minutes]
millis = parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
}
// Add 12 hours if it is PM
if (isPM && hoursMatchValue < 12) {
millis += 12 * MILLIS_PER_HOUR;
}
return millis;
};
export function millisToDelayString(millis: MaybeNumber, format: 'compact' | 'expanded' = 'compact'): string {
if (millis == null || millis === 0) {
return '';
+2 -2
View File
@@ -3,6 +3,6 @@ export function isMacOS() {
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '' : 'Alt';
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
export const deviceMod = isMacOS() ? '' : 'Ctrl';
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
+1
View File
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/;
+6
View File
@@ -17,4 +17,10 @@ declare global {
}
}
declare module 'react' {
interface CSSProperties {
[key: `--${string}`]: string | number;
}
}
export default {};
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -8,7 +9,7 @@ import { generateId } from 'ontime-utils';
import { maybeAxiosError } from '../../../../common/api/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import * as Panel from '../PanelUtils';
import { cycles } from './integrationUtils';
@@ -40,6 +41,13 @@ export default function OscIntegrations() {
control,
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
const onSubmit = async (values: OSCSettings) => {
if (values.portIn === values.portOut) {
setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' });
@@ -221,7 +229,7 @@ export default function OscIntegrations() {
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Payload</th>
<th className={style.halfWidth}>Arguments</th>
<th />
</tr>
</thead>
@@ -277,7 +285,7 @@ export default function OscIntegrations() {
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC payloads only allow ASCII characters',
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
},
})}
/>
@@ -1,9 +1,9 @@
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, TimerType } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { editorSettingsDefaults, useEditorSettings } from '../../../../common/stores/editorSettings';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import * as Panel from '../PanelUtils';
export default function EditorSettingsForm() {
@@ -15,9 +15,9 @@ export default function EditorSettingsForm() {
const setDangerTime = eventSettings.setDangerTime;
const setDefaultPublic = eventSettings.setDefaultPublic;
const durationInMs = forgivingStringToMillis(eventSettings.defaultDuration);
const warnTimeInMs = forgivingStringToMillis(eventSettings.defaultWarnTime);
const dangerTimeInMs = forgivingStringToMillis(eventSettings.defaultDangerTime);
const durationInMs = parseUserTime(eventSettings.defaultDuration);
const warnTimeInMs = parseUserTime(eventSettings.defaultWarnTime);
const dangerTimeInMs = parseUserTime(eventSettings.defaultDangerTime);
return (
<Panel.Section>
@@ -76,8 +76,13 @@ export default function SourcesPanel() {
setAuthenticationStatus(result.authenticated);
setSheetId(result.sheetId);
if (result.authenticated === 'authenticated' && result.sheetId) {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
try {
const names = await getWorksheetNames(result.sheetId);
setWorksheets(names);
} catch (error) {
const message = maybeAxiosError(error);
setError(`Error getting worksheets: ${message}`);
}
}
}
setImportFlow('gsheet');
@@ -1,13 +1,12 @@
import { Tooltip } from '@chakra-ui/react';
import { useLocalStorage } from '@mantine/hooks';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
import { Playback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useLocalStorage } from '../../../../common/hooks/useLocalStorage';
import { setPlayback } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import { tooltipDelayMid } from '../../../../ontimeConfig';
import TapButton from '../tap-button/TapButton';
@@ -19,10 +18,10 @@ interface AddTimeProps {
export default function AddTime(props: AddTimeProps) {
const { playback } = props;
const [time, setTime] = useLocalStorage('add-time', 300_000); // 5 minutes
const [time, setTime] = useLocalStorage({ key: 'add-time', defaultValue: 300_000 }); // 5 minutes
const handleTimeChange = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
const newTime = parseUserTime(value);
// cap add time to 1 hour
setTime(Math.min(newTime, MILLIS_PER_HOUR));
};
@@ -1,5 +1,12 @@
.extraRow {
.label {
display: block;
margin-top: 2rem;
font-size: $inner-section-text-size;
color: $label-gray;
}
.controls {
margin-top: 0.25rem;
display: flex;
gap: 0.5rem;
margin-top: 2rem;
}
@@ -4,10 +4,10 @@ import { IoPause } from '@react-icons/all-files/io5/IoPause';
import { IoPlay } from '@react-icons/all-files/io5/IoPlay';
import { IoStop } from '@react-icons/all-files/io5/IoStop';
import { Playback, SimpleDirection, SimplePlayback } from 'ontime-types';
import { parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
import { forgivingStringToMillis } from '../../../../common/utils/dateConfig';
import TapButton from '../tap-button/TapButton';
import style from './AuxTimer.module.scss';
@@ -29,33 +29,36 @@ export function AuxTimer() {
};
return (
<div className={style.extraRow}>
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<label className={style.label}>
Auxiliary Timer
<div className={style.controls}>
<AuxTimerInput />
<TapButton onClick={toggleDirection} aspect='tight'>
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid='aux-timer-direction' />}
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid='aux-timer-direction' />}
</TapButton>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
<TapButton
onClick={start}
theme={Playback.Play}
active={playback === SimplePlayback.Start}
disabled={!userCan.start}
>
<IoPlay data-testid='aux-timer-start' />
</TapButton>
<TapButton
onClick={pause}
theme={Playback.Pause}
active={playback === SimplePlayback.Pause}
disabled={!userCan.pause}
>
<IoPause data-testid='aux-timer-pause' />
</TapButton>
<TapButton onClick={stop} theme={Playback.Stop} disabled={!userCan.stop}>
<IoStop data-testid='aux-timer-stop' />
</TapButton>
</div>
</label>
);
}
@@ -64,7 +67,7 @@ function AuxTimerInput() {
const { setDuration } = setAuxTimer;
const handleTimeUpdate = (_field: string, value: string) => {
const newTime = forgivingStringToMillis(value);
const newTime = parseUserTime(value);
setDuration(newTime / 1000); //frontend api is seconds based;
};
@@ -1,6 +1,6 @@
import { PropsWithChildren } from 'react';
import { Tooltip } from '@chakra-ui/react';
import { Playback } from 'ontime-types';
import { Playback, TimerPhase } from 'ontime-types';
import { dayInMs, millisToMinutes, millisToSeconds, millisToString } from 'ontime-utils';
import { useTimer } from '../../../../common/hooks/useSocket';
@@ -44,7 +44,7 @@ export default function PlaybackTimer(props: PropsWithChildren<PlaybackTimerProp
const isRolling = playback === Playback.Roll;
const isWaiting = timer.secondaryTimer !== null && timer.secondaryTimer > 0 && timer.current === null;
const isOvertime = timer.current !== null && timer.current < 0;
const isOvertime = timer.phase === TimerPhase.Overtime;
const hasAddedTime = Boolean(timer.addedTime);
const rollLabel = isRolling ? 'Roll mode active' : '';
@@ -16,12 +16,9 @@ interface TimerDisplayProps {
export default function TimerDisplay(props: TimerDisplayProps) {
const { time } = props;
if (time == null) {
return <div className={style.timer}>{timerPlaceholder}</div>;
}
const isNegative = time < 0;
const display = millisToString(Math.abs(time), { fallback: timerPlaceholder });
const isNegative = (time ?? 0) < 0;
const display =
time == null ? timerPlaceholder : millisToString(time, { fallback: timerPlaceholder }).replace('-', '');
const classes = cx([style.timer, isNegative ? style.finished : null]);
return <div className={classes}>{display}</div>;
@@ -36,7 +36,6 @@ $table-header-font-size: calc(1rem - 3px);
min-width: 2rem;
text-align: right;
font-weight: 400;
position: sticky;
left: 0;
z-index: 1;
+37 -7
View File
@@ -1,10 +1,10 @@
import { useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
@@ -25,16 +25,25 @@ interface CuesheetProps {
}
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
const { followSelected, showSettings, showDelayBlock, showPrevious } = useCuesheetSettings();
const { followSelected, showSettings, showDelayBlock, showPrevious, showIndexColumn } = useCuesheetSettings();
const [columnVisibility, setColumnVisibility] = useLocalStorage('table-hidden', {});
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
const [columnSizing, setColumnSizing] = useLocalStorage('table-sizes', {});
const [columnVisibility, setColumnVisibility] = useLocalStorage({ key: 'table-hidden', defaultValue: {} });
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>({
key: 'table-order',
defaultValue: initialColumnOrder,
});
const [columnSizing, setColumnSizing] = useLocalStorage({ key: 'table-sizes', defaultValue: {} });
const selectedRef = useRef<HTMLTableRowElement | null>(null);
const tableContainerRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: selectedRef, scrollRef: tableContainerRef, doFollow: followSelected });
// keep column order in sync with columns
useEffect(() => {
const order = columns.map((col) => col.id as string);
saveColumnOrder(order);
}, [columns, saveColumnOrder]);
const table = useReactTable({
data,
columns,
@@ -64,6 +73,26 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
setColumnSizing({});
};
const reorder = useCallback(
(fromId: string, toId: string) => {
// get index of from
const fromIndex = columnOrder.indexOf(fromId);
// get index of to
const toIndex = columnOrder.indexOf(toId);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
},
[columnOrder, saveColumnOrder],
);
const headerGroups = table.getHeaderGroups();
const rowModel = table.getRowModel();
const allLeafColumns = table.getAllLeafColumns();
@@ -83,7 +112,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
)}
<div ref={tableContainerRef} className={style.cuesheetContainer}>
<table className={style.cuesheet}>
<CuesheetHeader headerGroups={headerGroups} />
<CuesheetHeader headerGroups={headerGroups} saveColumnOrder={reorder} showIndexColumn={showIndexColumn} />
<tbody>
{rowModel.rows.map((row) => {
const key = row.original.id;
@@ -136,6 +165,7 @@ export default function Cuesheet({ data, columns, handleUpdate, selectedId }: Cu
selectedRef={isSelected ? selectedRef : undefined}
skip={row.original.skip}
colour={row.original.colour}
showIndexColumn={showIndexColumn}
>
{row.getVisibleCells().map((cell) => {
return (
@@ -1,23 +1,17 @@
import { memo, useEffect } from 'react';
import { Tooltip } from '@chakra-ui/react';
import {
closestCenter,
closestCorners,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
import { flexRender, HeaderGroup } from '@tanstack/react-table';
import { OntimeRundownEntry } from 'ontime-types';
import { useLocalStorage } from '../../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { tooltipDelayFast } from '../../../ontimeConfig';
import { initialColumnOrder } from '../cuesheetCols';
import { SortableCell } from './SortableCell';
@@ -25,17 +19,12 @@ import style from '../Cuesheet.module.scss';
interface CuesheetHeaderProps {
headerGroups: HeaderGroup<OntimeRundownEntry>[];
saveColumnOrder: (fromId: string, toId: string) => void;
showIndexColumn: boolean;
}
function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups } = props;
const [columnOrder, saveColumnOrder] = useLocalStorage<string[]>('table-order', initialColumnOrder);
useEffect(() => {
if (!localStorage.getItem('table-order')) {
saveColumnOrder(initialColumnOrder);
}
}, [saveColumnOrder]);
export default function CuesheetHeader(props: CuesheetHeaderProps) {
const { headerGroups, saveColumnOrder, showIndexColumn } = props;
const handleOnDragEnd = (event: DragEndEvent) => {
const { delta, active, over } = event;
@@ -45,21 +34,7 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
// cancel if we do not have an over id
if (over?.id == null) return;
// get index of from
const fromIndex = columnOrder.indexOf(active.id as string);
// get index of to
const toIndex = columnOrder.indexOf(over.id as string);
if (toIndex === -1) {
return;
}
const reorderedCols = [...columnOrder];
const reorderedItem = reorderedCols.splice(fromIndex, 1);
reorderedCols.splice(toIndex, 0, reorderedItem[0]);
saveColumnOrder(reorderedCols);
saveColumnOrder(active.id as string, over.id as string);
};
const sensors = useSensors(
@@ -75,9 +50,6 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
tolerance: 50,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
return (
@@ -86,13 +58,9 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
const key = headerGroup.id;
return (
<DndContext key={key} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleOnDragEnd}>
<DndContext key={key} sensors={sensors} collisionDetection={closestCorners} onDragEnd={handleOnDragEnd}>
<tr key={headerGroup.id}>
<th className={style.indexColumn}>
<Tooltip label='Event Order' openDelay={tooltipDelayFast}>
#
</Tooltip>
</th>
<th className={style.indexColumn}>{showIndexColumn && '#'}</th>
<SortableContext key={key} items={headerGroup.headers} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header) => {
const width = header.getSize();
@@ -119,5 +87,3 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
</thead>
);
}
export default memo(CuesheetHeader);
@@ -8,6 +8,7 @@ const pastOpacity = '0.2';
interface EventRowProps {
eventIndex: number;
showIndexColumn: boolean;
isPast?: boolean;
selectedRef?: MutableRefObject<HTMLTableRowElement | null>;
skip?: boolean;
@@ -15,7 +16,7 @@ interface EventRowProps {
}
function EventRow(props: PropsWithChildren<EventRowProps>) {
const { children, eventIndex, isPast, selectedRef, skip, colour } = props;
const { children, eventIndex, isPast, selectedRef, skip, colour, showIndexColumn } = props;
const ownRef = useRef<HTMLTableRowElement>(null);
const [isVisible, setIsVisible] = useState(false);
@@ -56,7 +57,7 @@ function EventRow(props: PropsWithChildren<EventRowProps>) {
ref={selectedRef ?? ownRef}
>
<td className={style.indexColumn} style={{ backgroundColor: bgColour, color: textColour.color }}>
{eventIndex}
{showIndexColumn && eventIndex}
</td>
{isVisible ? children : null}
</tr>
@@ -24,11 +24,13 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
const { columns, handleResetResizing, handleResetReordering, handleClearToggles } = props;
const {
followSelected,
showIndexColumn,
toggleFollow,
showPrevious,
togglePreviousVisibility,
showDelayBlock,
showDelayedTimes,
toggleIndexColumn,
toggleDelayedTimes,
toggleDelayVisibility,
} = useCuesheetSettings();
@@ -63,6 +65,10 @@ function CuesheetTableSettings(props: CuesheetTableSettingsProps) {
<Switch variant='ontime' size='sm' isChecked={showPrevious} onChange={() => togglePreviousVisibility()} />
Show past events
</label>
<label className={style.option}>
<Switch variant='ontime' size='sm' isChecked={showIndexColumn} onChange={() => toggleIndexColumn()} />
Show Event Order
</label>
</div>
<div className={style.sectionTitle}>Delay Flow</div>
<div className={style.options}>
@@ -4,6 +4,7 @@ import { booleanFromLocalStorage } from '../../../common/utils/localStorage';
interface CuesheetSettings {
showSettings: boolean;
showIndexColumn: boolean;
followSelected: boolean;
showPrevious: boolean;
showDelayBlock: boolean;
@@ -12,6 +13,7 @@ interface CuesheetSettings {
toggleSettings: (newValue?: boolean) => void;
toggleFollow: (newValue?: boolean) => void;
togglePreviousVisibility: (newValue?: boolean) => void;
toggleIndexColumn: (newValue?: boolean) => void;
toggleDelayVisibility: (newValue?: boolean) => void;
toggleDelayedTimes: (newValue?: boolean) => void;
}
@@ -27,11 +29,13 @@ enum CuesheetKeys {
Follow = 'ontime-cuesheet-follow-selected',
DelayVisibility = 'ontime-cuesheet-show-delay',
PreviousVisibility = 'ontime-cuesheet-show-previous',
ColumnIndex = 'ontime-cuesheet-show-index-column',
DelayedTimes = 'ontime-cuesheet-show-delayed',
}
export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
showSettings: false,
showIndexColumn: booleanFromLocalStorage(CuesheetKeys.ColumnIndex, true),
followSelected: booleanFromLocalStorage(CuesheetKeys.Follow, false),
showPrevious: booleanFromLocalStorage(CuesheetKeys.PreviousVisibility, true),
showDelayBlock: booleanFromLocalStorage(CuesheetKeys.DelayVisibility, true),
@@ -44,6 +48,12 @@ export const useCuesheetSettings = create<CuesheetSettings>()((set) => ({
localStorage.setItem(CuesheetKeys.Follow, String(followSelected));
return { followSelected };
}),
toggleIndexColumn: (newValue?: boolean) =>
set((state) => {
const showIndexColumn = toggle(state.showIndexColumn, newValue);
localStorage.setItem(CuesheetKeys.ColumnIndex, String(showIndexColumn));
return { showIndexColumn };
}),
togglePreviousVisibility: (newValue?: boolean) =>
set((state) => {
const showPrevious = toggle(state.showPrevious, newValue);
+56 -41
View File
@@ -14,6 +14,7 @@ import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
import { useEventSelection } from './useEventSelection';
import style from './Rundown.module.scss';
@@ -33,7 +34,9 @@ export default function Rundown({ data }: RundownProps) {
const { entryCopyId, setEntryCopyId } = useEntryCopy();
// cursor
const { cursor, mode: appMode, setCursor } = useAppMode();
const { mode: appMode } = useAppMode();
const { clearSelectedEvents, setSelectedEvents, cursor } = useEventSelection();
const cursorRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
useFollowComponent({ followRef: cursorRef, scrollRef, doFollow: appMode === AppMode.Run });
@@ -44,34 +47,42 @@ export default function Rundown({ data }: RundownProps) {
const deleteAtCursor = useCallback(
(cursor: string | null) => {
if (!cursor) return;
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
deleteEvent(cursor);
setCursor(previous);
const { entry, index } = getPreviousNormal(rundown, order, cursor);
deleteEvent([cursor]);
if (entry && index !== null) {
setSelectedEvents({ id: entry.id, selectMode: 'click', index });
}
},
[deleteEvent, order, rundown, setCursor],
[rundown, order, deleteEvent, setSelectedEvents],
);
const insertAtCursor = useCallback(
(type: SupportedEvent | 'clone', cursor: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, cursor ?? '').entry?.id ?? null : cursor;
if (adjustedCursor === null) {
const insertCopyAtId = useCallback(
(atId: string | null, copyId: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, atId ?? '').entry?.id ?? null : atId;
if (copyId === null) {
// we cant clone without selection
if (type === 'clone') {
return;
}
return;
}
const cloneEntry = rundown[copyId];
if (cloneEntry?.type === SupportedEvent.Event) {
//if we don't have a cursor add the new event on top
const newEvent = cloneEvent(cloneEntry, adjustedCursor ?? undefined);
addEvent(newEvent);
}
},
[addEvent, order, rundown],
);
const insertAtId = useCallback(
(type: SupportedEvent, id: string | null, above = false) => {
const adjustedCursor = above ? getPreviousNormal(rundown, order, id ?? '').entry?.id ?? null : id;
if (adjustedCursor === null) {
// the only thing to do is adding an event at top
addEvent({ type });
return;
}
if (type === 'clone') {
const cursorEvent = rundown[adjustedCursor];
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
if (type === SupportedEvent.Event) {
const newEvent = {
type: SupportedEvent.Event,
};
@@ -92,23 +103,26 @@ export default function Rundown({ data }: RundownProps) {
if (order.length < 1) {
return;
}
let newCursor: string | undefined;
let newCursor: string | null;
let newIndex: number | null;
if (cursor === null) {
// there is no cursor, we select the first or last depending on direction if it exists
newCursor = direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id;
newCursor =
(direction === 'up' ? getLastNormal(rundown, order)?.id : getFirstNormal(rundown, order)?.id) ?? null;
newIndex = direction === 'up' ? order.length : 0;
} else {
// otherwise we select the next or previous
newCursor =
direction === 'up'
? getPreviousNormal(rundown, order, cursor).entry?.id
: getNextNormal(rundown, order, cursor).entry?.id;
const selected =
direction === 'up' ? getPreviousNormal(rundown, order, cursor) : getNextNormal(rundown, order, cursor);
newCursor = selected.entry?.id ?? null;
newIndex = selected.index;
}
if (newCursor) {
setCursor(newCursor);
if (newCursor && newIndex !== null) {
setSelectedEvents({ id: newCursor, selectMode: 'click', index: newIndex });
}
},
[order, rundown, setCursor],
[order, rundown, setSelectedEvents],
);
const moveEntry = useCallback(
@@ -134,22 +148,22 @@ export default function Rundown({ data }: RundownProps) {
['alt + mod + ArrowDown', () => moveEntry(cursor, 'down'), { preventDefault: true }],
['alt + mod + ArrowUp', () => moveEntry(cursor, 'up'), { preventDefault: true }],
['Escape', () => setCursor(null), { preventDefault: true }],
['Escape', () => clearSelectedEvents(), { preventDefault: true }],
['mod + Backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
['alt + E', () => insertAtCursor(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtCursor(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + E', () => insertAtId(SupportedEvent.Event, cursor), { preventDefault: true }],
['alt + shift + E', () => insertAtId(SupportedEvent.Event, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtCursor(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtCursor(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + B', () => insertAtId(SupportedEvent.Block, cursor), { preventDefault: true }],
['alt + shift + B', () => insertAtId(SupportedEvent.Block, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtCursor(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtCursor(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['alt + D', () => insertAtId(SupportedEvent.Delay, cursor), { preventDefault: true }],
['alt + shift + D', () => insertAtId(SupportedEvent.Delay, cursor, true), { preventDefault: true }],
['mod + C', () => setEntryCopyId(cursor), { preventDefault: true }],
['mod + V', () => insertAtCursor('clone', entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertAtCursor('clone', entryCopyId, true), { preventDefault: true }],
['mod + V', () => insertCopyAtId(cursor, entryCopyId), { preventDefault: true }],
['mod + shift + V', () => insertCopyAtId(cursor, entryCopyId, true), { preventDefault: true }],
['alt + backspace', () => deleteAtCursor(cursor), { preventDefault: true }],
]);
@@ -165,8 +179,9 @@ export default function Rundown({ data }: RundownProps) {
if (appMode !== AppMode.Run || !featureData?.selectedEventId) {
return;
}
setCursor(featureData.selectedEventId);
}, [appMode, featureData.selectedEventId, setCursor]);
const index = order.findIndex((id) => id === featureData.selectedEventId);
setSelectedEvents({ id: featureData.selectedEventId, selectMode: 'click', index });
}, [appMode, featureData.selectedEventId, order, setSelectedEvents]);
const handleOnDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
@@ -185,7 +200,7 @@ export default function Rundown({ data }: RundownProps) {
};
if (statefulEntries.length < 1) {
return <RundownEmpty handleAddNew={() => insertAtCursor(SupportedEvent.Event, null)} />;
return <RundownEmpty handleAddNew={() => insertAtId(SupportedEvent.Event, cursor)} />;
}
let previousStart: MaybeNumber = null;
@@ -3,7 +3,6 @@ import { MaybeNumber, OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent
import { useEventAction } from '../../common/hooks/useEventAction';
import useMemoisedFn from '../../common/hooks/useMemoisedFn';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEmitLog } from '../../common/stores/logger';
import { cloneEvent } from '../../common/utils/eventsManager';
@@ -56,20 +55,15 @@ export default function RundownEntry(props: RundownEntryProps) {
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
const cursor = useAppMode((state) => state.cursor);
const setCursor = useAppMode((state) => state.setCursor);
const { selectedEvents, clearSelectedEvents } = useEventSelection();
const { selectedEvents, unselect, clearSelectedEvents } = useEventSelection();
const removeOpenEvent = useCallback(() => {
if (selectedEvents.has(data.id)) {
clearSelectedEvents();
}
unselect(data.id);
}, [unselect, data.id]);
// clear cursor if we are deleting the event that is currently selected
if (cursor === data.id) {
setCursor(null);
}
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
const clearMultiSelection = useCallback(() => {
clearSelectedEvents();
}, [clearSelectedEvents]);
// Create / delete new events
type FieldValue = {
@@ -111,10 +105,12 @@ export default function RundownEntry(props: RundownEntryProps) {
return swapEvents({ from: value as string, to: data.id });
}
case 'delete': {
if (selectedEvents.has(data.id)) {
removeOpenEvent();
if (selectedEvents.size > 1) {
clearMultiSelection();
return deleteEvent(Array.from(selectedEvents));
}
return deleteEvent(data.id);
removeOpenEvent();
return deleteEvent([data.id]);
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
@@ -134,7 +130,7 @@ export default function RundownEntry(props: RundownEntryProps) {
if (selectedEvents.size > 1) {
const changes: Partial<OntimeEvent> = { [field]: value };
batchUpdateEvents(changes, Array.from(selectedEvents));
return clearSelectedEvents();
return;
}
if (field in data) {
// @ts-expect-error -- not sure how to type this
@@ -1,7 +1,9 @@
import { memo } from 'react';
import { IoArrowUp } from '@react-icons/all-files/io5/IoArrowUp';
import { ContextMenu } from '../../common/components/context-menu/ContextMenu';
import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary';
import { useAppMode } from '../../common/stores/appModeStore';
import { handleLinks } from '../../common/utils/linkUtils';
import { cx } from '../../common/utils/styleUtils';
@@ -12,6 +14,8 @@ import style from './RundownExport.module.scss';
const RundownExport = () => {
const isExtracted = window.location.pathname.includes('/rundown');
const appMode = useAppMode((state) => state.mode);
const hideSideBar = isExtracted && appMode === 'run';
const classes = cx([style.rundownExport, isExtracted && style.extracted]);
@@ -21,14 +25,18 @@ const RundownExport = () => {
<div className={style.list}>
<ErrorBoundary>
{!isExtracted && <IoArrowUp className={style.corner} onClick={(event) => handleLinks(event, 'rundown')} />}
<RundownWrapper />
</ErrorBoundary>
</div>
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
<ContextMenu>
<RundownWrapper />
</ContextMenu>
</ErrorBoundary>
</div>
{!hideSideBar && (
<div className={style.side}>
<ErrorBoundary>
<EventEditor />
</ErrorBoundary>
</div>
)}
</div>
</div>
);
@@ -50,7 +50,7 @@ export default function DelayBlock(props: DelayBlockProps) {
};
const cancelDelayHandler = () => {
deleteEvent(data.id);
deleteEvent([data.id]);
};
const blockClasses = cx([style.delay, hasCursor ? style.hasCursor : null]);
@@ -11,7 +11,6 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { EndAction, MaybeNumber, MaybeString, OntimeEvent, Playback, TimerType, TimeStrategy } from 'ontime-types';
import { useContextMenu } from '../../../common/hooks/useContextMenu';
import { useAppMode } from '../../../common/stores/appModeStore';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import type { EventItemActions } from '../RundownEntry';
import { useEventIdSwapping } from '../useEventIdSwapping';
@@ -88,7 +87,6 @@ export default function EventBlock(props: EventBlockProps) {
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
const { selectedEvents, setSelectedEvents } = useEventSelection();
const setCursor = useAppMode((state) => state.setCursor);
const handleRef = useRef<null | HTMLSpanElement>(null);
const [isVisible, setIsVisible] = useState(false);
@@ -113,6 +111,7 @@ export default function EventBlock(props: EventBlockProps) {
value: false,
}),
},
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
]
: [
{
@@ -230,7 +229,6 @@ export default function EventBlock(props: EventBlockProps) {
const index = eventIndex - 1;
const editMode = getSelectionMode(event);
setSelectedEvents({ id: eventId, index, selectMode: editMode });
setCursor(eventId);
};
return (
@@ -40,7 +40,7 @@ function EventEditorEmpty() {
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<AuxKey>/</AuxKey>
<Kbd></Kbd>
<Kbd></Kbd>
</td>
</tr>
<tr>
@@ -56,7 +56,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>V</Kbd>
</td>
@@ -74,7 +74,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceMod}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Backspace</Kbd>
</td>
</tr>
<tr className={style.spacer} />
@@ -91,7 +91,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>E</Kbd>
</td>
@@ -109,7 +109,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>B</Kbd>
</td>
@@ -127,7 +127,7 @@ function EventEditorEmpty() {
<td>
<Kbd>{deviceAlt}</Kbd>
<AuxKey>+</AuxKey>
<Kbd></Kbd>
<Kbd>Shift</Kbd>
<AuxKey>+</AuxKey>
<Kbd>D</Kbd>
</td>
@@ -1,11 +1,11 @@
import { memo } from 'react';
import { Select, Switch } from '@chakra-ui/react';
import { EndAction, MaybeString, TimerType, TimeStrategy } from 'ontime-types';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
import { useEventAction } from '../../../../common/hooks/useEventAction';
import { forgivingStringToMillis, millisToDelayString } from '../../../../common/utils/dateConfig';
import { millisToDelayString } from '../../../../common/utils/dateConfig';
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
import style from '../EventEditor.module.scss';
@@ -51,7 +51,7 @@ const EventEditorTimes = (props: EventEditorTimesProps) => {
}
if (field === 'timeWarning' || field === 'timeDanger') {
const newTime = forgivingStringToMillis(value as string);
const newTime = parseUserTime(value as string);
updateEvent({ id: eventId, [field]: newTime });
return;
}
@@ -17,7 +17,6 @@ import { useEventSelection } from '../useEventSelection';
export default function RundownMenu() {
const clearSelectedEvents = useEventSelection((state) => state.clearSelectedEvents);
const setCursor = useAppMode((state) => state.setCursor);
const appMode = useAppMode((state) => state.mode);
const { deleteAllEvents } = useEventAction();
@@ -27,9 +26,8 @@ export default function RundownMenu() {
const deleteAll = useCallback(() => {
deleteAllEvents();
clearSelectedEvents();
setCursor(null);
onClose();
}, [clearSelectedEvents, deleteAllEvents, onClose, setCursor]);
}, [clearSelectedEvents, deleteAllEvents, onClose]);
return (
<>
@@ -1,5 +1,5 @@
import { MouseEvent } from 'react';
import { isOntimeEvent, OntimeEvent, RundownCached } from 'ontime-types';
import { isOntimeEvent, MaybeNumber, MaybeString, OntimeEvent, RundownCached } from 'ontime-types';
import { create } from 'zustand';
import { RUNDOWN } from '../../common/api/constants';
@@ -10,21 +10,25 @@ export type SelectionMode = 'shift' | 'click' | 'ctrl';
interface EventSelectionStore {
selectedEvents: Set<string>;
anchoredIndex: number | null;
anchoredIndex: MaybeNumber;
cursor: MaybeString;
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
clearSelectedEvents: () => void;
clearMultiSelect: () => void;
unselect: (id: string) => void;
}
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
selectedEvents: new Set(),
anchoredIndex: null,
cursor: null,
setSelectedEvents: (selectionArgs) => {
const { id, index, selectMode } = selectionArgs;
const { selectedEvents, anchoredIndex } = get();
// on click, we replace selection with event
if (selectMode === 'click') {
return set({ selectedEvents: new Set([id]), anchoredIndex: index });
return set({ selectedEvents: new Set([id]), anchoredIndex: index, cursor: id });
}
// on ctrl + click, we toggle the selection of that event
@@ -37,6 +41,7 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
return set({
selectedEvents: selectedEvents.add(id),
anchoredIndex: index,
cursor: id,
});
}
@@ -81,7 +86,17 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
});
}
},
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: null, cursor: null }),
clearMultiSelect: () => {
const { selectedEvents } = get();
const [firstSelected] = selectedEvents;
set({ selectedEvents: new Set(firstSelected || undefined), anchoredIndex: null });
},
unselect: (id: string) => {
const { selectedEvents } = get();
selectedEvents.delete(id);
set({ selectedEvents });
},
}));
export function getSelectionMode(event: MouseEvent): SelectionMode {
@@ -82,8 +82,6 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
...timer,
clock,
timerType: eventNow?.timerType ?? null,
timeWarning: eventNow?.timeWarning ?? null,
timeDanger: eventNow?.timeWarning ?? null,
};
// prevent render until we get all the data we need
@@ -84,7 +84,9 @@ export function getFormattedTimer(
}
let display = millisToString(timeToParse);
display = removeLeadingZero(display);
if (options.removeLeadingZero) {
display = removeLeadingZero(display);
}
if (options.removeSeconds) {
display = formatDisplayWithMinutes(display, localisedMinutes);
@@ -20,11 +20,11 @@
}
.timer {
opacity: 1;
font-family: var(--font-family-bold-override, $timer-bold-font-family) ;
font-size: 20vw;
position: relative;
color: var(--timer-color-override, $timer-color);
opacity: 1;
color: var(--timer-color-override, var(--phase-color));
transition: $viewer-transition-time;
transition-property: opacity;
background-color: transparent;
@@ -42,42 +42,6 @@
/* =================== OVERLAY ===================*/
.message-overlay {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: $viewer-overlay-bg-color;
z-index: -1;
opacity: 0;
transition: $viewer-transition-time;
&--active {
opacity: 1;
transition: $viewer-transition-time;
transition-property: opacity;
z-index: 2;
}
}
.message {
width: inherit;
padding: 2vw;
position: absolute;
top: 50%;
left: 50%;
color: white;
transform: translate(-50%, -50%);
-ms-transform: translate(-50%, -50%);
font-size: 15vw;
line-height: 30vh;
text-align: center;
font-weight: 600;
}
.end-message {
text-align: center;
font-size: 12vw;
@@ -1,5 +1,5 @@
import { useSearchParams } from 'react-router-dom';
import { Playback, TimerMessage, TimerType, ViewSettings } from 'ontime-types';
import { Playback, TimerPhase, TimerType, ViewSettings } from 'ontime-types';
import { overrideStylesURL } from '../../../common/api/constants';
import { MINIMAL_TIMER_OPTIONS } from '../../../common/components/view-params-editor/constants';
@@ -15,13 +15,12 @@ import './MinimalTimer.scss';
interface MinimalTimerProps {
isMirrored: boolean;
pres: TimerMessage;
time: ViewExtendedTimer;
viewSettings: ViewSettings;
}
export default function MinimalTimer(props: MinimalTimerProps) {
const { isMirrored, pres, time, viewSettings } = props;
const { isMirrored, time, viewSettings } = props;
const { shouldRender } = useRuntimeStylesheet(viewSettings?.overrideStyles && overrideStylesURL);
const { getLocalizedString } = useTranslation();
const [searchParams] = useSearchParams();
@@ -117,9 +116,6 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const hideOvertime = searchParams.get('hideovertime');
userOptions.hideOvertime = isStringBoolean(hideOvertime);
const hideMessagesOverlay = searchParams.get('hidemessages');
userOptions.hideMessagesOverlay = isStringBoolean(hideMessagesOverlay);
const hideEndMessage = searchParams.get('hideendmessage');
userOptions.hideEndMessage = isStringBoolean(hideEndMessage);
@@ -128,18 +124,16 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const showOverlay = pres.text !== '' && pres.visible;
const isPlaying = time.playback !== Playback.Pause;
const showEndMessage = (time.current ?? 0) < 0 && viewSettings.endMessage && !hideEndMessage;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const showFinished = finished && !userOptions?.hideOvertime && (time.timerType !== TimerType.Clock || showEndMessage);
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
const finished = time.phase === TimerPhase.Overtime;
const showEndMessage = finished && viewSettings.endMessage && !hideEndMessage;
const showFinished = finished && !userOptions?.hideOvertime && (shouldShowModifiers || showEndMessage);
const showProgress = time.playback !== Playback.Stop;
const showWarning = (time.current ?? 1) < (time.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (time.timeDanger ?? 0);
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
let timerColor = viewSettings.normalColor;
if (!timerIsTimeOfDay && showProgress && showWarning) timerColor = viewSettings.warningColor;
@@ -155,11 +149,8 @@ export default function MinimalTimer(props: MinimalTimerProps) {
const timerFontSize = (89 / (stageTimerCharacters - 1)) * (userOptions.size || 1);
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''} ${
showBlinking ? (showOverlay ? '' : 'blink') : ''
}`;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''} ${showBlackout ? 'blackout' : ''}`;
const timerClasses = `timer ${!isPlaying ? 'timer--paused' : ''} ${showFinished ? 'timer--finished' : ''}`;
const baseClasses = `minimal-timer ${isMirrored ? 'mirror' : ''}`;
return (
<div
className={showFinished ? `${baseClasses} minimal-timer--finished` : baseClasses}
@@ -171,25 +162,18 @@ export default function MinimalTimer(props: MinimalTimerProps) {
data-testid='minimal-timer'
>
<ViewParamsEditor paramFields={MINIMAL_TIMER_OPTIONS} />
{!hideMessagesOverlay && (
<div className={showOverlay ? 'message-overlay message-overlay--active' : 'message-overlay'}>
<div className={`message ${showBlinking ? 'blink' : ''}`}>{pres.text}</div>
</div>
)}
{showEndMessage ? (
<div className={`end-message ${showBlinking ? (showOverlay ? '' : 'blink') : ''}`}>
{viewSettings.endMessage}
</div>
<div className='end-message'>{viewSettings.endMessage}</div>
) : (
<div
className={timerClasses}
style={{
color: timerColor,
fontSize: `${timerFontSize}vw`,
fontFamily: userOptions.font,
top: userOptions.top,
left: userOptions.left,
backgroundColor: userOptions.textBackground,
'--phase-color': timerColor,
}}
>
{display}
@@ -109,7 +109,7 @@
.timer {
opacity: 1;
font-family: var(--font-family-override, $viewer-font-family);
color: var(--timer-color-override, $timer-color);
color: var(--timer-color-override, var(--phase-color));
line-height: 0.9em;
text-align: center;
letter-spacing: 0.05em;
@@ -7,6 +7,7 @@ import {
Playback,
Settings,
TimerMessage,
TimerPhase,
TimerType,
ViewSettings,
} from 'ontime-types';
@@ -76,6 +77,7 @@ export default function Timer(props: TimerProps) {
hideMessage: false,
hideTimerSeconds: false,
hideClockSeconds: false,
removeLeadingZeros: true,
};
const hideClock = searchParams.get('hideClock');
@@ -97,6 +99,9 @@ export default function Timer(props: TimerProps) {
const hideTimerSeconds = searchParams.get('hideTimerSeconds');
userOptions.hideTimerSeconds = isStringBoolean(hideTimerSeconds);
const showLeadingZeros = searchParams.get('showLeadingZeros');
userOptions.removeLeadingZeros = !isStringBoolean(showLeadingZeros);
const secondarySource = searchParams.get('secondary-src');
const secondaryTextNow = getPropertyValue(eventNow, secondarySource);
const secondaryTextNext = getPropertyValue(eventNext, secondarySource);
@@ -106,14 +111,15 @@ export default function Timer(props: TimerProps) {
const timerIsTimeOfDay = time.timerType === TimerType.Clock;
const finished = time.playback === Playback.Play && (time.current ?? 0) < 0 && time.startedAt;
const finished = time.phase === TimerPhase.Overtime;
const totalTime = (time.duration ?? 0) + (time.addedTime ?? 0);
const showEndMessage = (time.current ?? 1) < 0 && viewSettings.endMessage;
const shouldShowModifiers = time.timerType !== TimerType.Clock && time.timerType !== TimerType.CountUp;
const showEndMessage = finished && viewSettings.endMessage;
const showProgress = time.playback !== Playback.Stop;
const showFinished = finished && (time.timerType !== TimerType.Clock || showEndMessage);
const showWarning = (time.current ?? 1) < (eventNow?.timeWarning ?? 0);
const showDanger = (time.current ?? 1) < (eventNow?.timeDanger ?? 0);
const showFinished = finished && (shouldShowModifiers || showEndMessage);
const showWarning = shouldShowModifiers && time.phase === TimerPhase.Warning;
const showDanger = shouldShowModifiers && time.phase === TimerPhase.Danger;
const showBlinking = pres.blink;
const showBlackout = pres.blackout;
const showClock = time.timerType !== TimerType.Clock;
@@ -126,7 +132,7 @@ export default function Timer(props: TimerProps) {
const stageTimer = getTimerByType(viewSettings.freezeEnd, time);
const display = getFormattedTimer(stageTimer, time.timerType, getLocalizedString('common.minutes'), {
removeSeconds: userOptions.hideTimerSeconds,
removeLeadingZero: true,
removeLeadingZero: userOptions.removeLeadingZeros,
});
const stageTimerCharacters = display.replace('/:/g', '').length;
@@ -169,7 +175,7 @@ export default function Timer(props: TimerProps) {
className={timerClasses}
style={{
fontSize: `${timerFontSize}vw`,
color: timerColor,
'--phase-color': timerColor,
}}
>
{display}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.0.3",
"version": "3.1.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -12,7 +12,7 @@
"license": "AGPL-3.0-only",
"main": "main.js",
"devDependencies": {
"electron": "^28.0.0",
"electron": "^28.3.3",
"electron-builder": "^24.13.3",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
+1 -1
View File
@@ -124,7 +124,7 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{
label: 'Operator',
click: async () => {
await shell.openExternal(`${urlBase}/operator`);
await shell.openExternal(`${urlBase}/op`);
},
},
],
+1 -4
View File
@@ -2,11 +2,10 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.0.3",
"version": "3.1.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"body-parser": "^1.20.0",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.18.2",
@@ -20,8 +19,6 @@
"node-osc": "^9.0.2",
"node-xlsx": "^0.23.0",
"ontime-utils": "workspace:*",
"passport": "^0.6.0",
"passport-local": "~1.0.0",
"steno": "^3.1.0",
"ts-essentials": "^9.4.1",
"ws": "^8.13.0"
@@ -19,6 +19,9 @@ import * as projectService from '../../services/project-service/ProjectService.j
import { ensureJsonExtension } from '../../utils/fileManagement.js';
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
import { appStateService } from '../../services/app-state-service/AppStateService.js';
import { oscIntegration } from '../../services/integration-service/OscIntegration.js';
import { httpIntegration } from '../../services/integration-service/HttpIntegration.js';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
// all fields are optional in validation
@@ -32,6 +35,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
const patchDb: DatabaseModel = { rundown, project, settings, viewSettings, urlPresets, customFields, osc, http };
const newData = await projectService.applyDataModel(patchDb);
res.status(200).send(newData);
} catch (error) {
const message = getErrorMessage(error);
@@ -125,6 +129,12 @@ export async function postProjectFile(req: Request, res: Response<MessageRespons
await projectService.handleUploadedFile(path, filename);
await projectService.applyProjectFile(filename, options);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({
message: `Loaded project ${filename}`,
});
@@ -159,6 +169,12 @@ export async function loadProject(req: Request, res: Response<MessageResponse |
await projectService.applyProjectFile(name);
const oscSettings = await DataProvider.getOsc();
const httpSettings = await DataProvider.getHttp();
oscIntegration.init(oscSettings);
httpIntegration.init(httpSettings);
res.status(201).send({
message: `Loaded project ${name}`,
});
@@ -119,10 +119,10 @@ export async function rundownDelete(_req: Request, res: Response<MessageResponse
}
}
export async function deleteEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
export async function deletesEventById(req: Request, res: Response<MessageResponse | ErrorResponse>) {
try {
await deleteEvent(req.params.eventId);
res.status(204).send({ message: 'Event deleted' });
await deleteEvent(req.body.ids);
res.status(204).send({ message: 'Events deleted' });
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
@@ -1,7 +1,7 @@
import express from 'express';
import {
deleteEventById,
deletesEventById,
rundownApplyDelay,
rundownBatchPut,
rundownDelete,
@@ -14,6 +14,7 @@ import {
} from './rundown.controller.js';
import {
paramsMustHaveEventId,
rundownArrayOfIds,
rundownBatchPutValidator,
rundownPostValidator,
rundownPutValidator,
@@ -35,5 +36,5 @@ router.patch('/reorder/', rundownReorderValidator, rundownReorder);
router.patch('/swap', rundownSwapValidator, rundownSwap);
router.patch('/applydelay/:eventId', paramsMustHaveEventId, rundownApplyDelay);
router.delete('/', rundownArrayOfIds, deletesEventById);
router.delete('/all', rundownDelete);
router.delete('/:eventId', paramsMustHaveEventId, deleteEventById);
@@ -64,3 +64,14 @@ export const paramsMustHaveEventId = [
next();
},
];
export const rundownArrayOfIds = [
body('ids').isArray().exists(),
body('ids.*').isString(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
@@ -1,4 +1,5 @@
import { ErrorResponse, Settings } from 'ontime-types';
import { getErrorMessage, obfuscate } from 'ontime-utils';
import { Request, Response } from 'express';
@@ -6,8 +7,6 @@ import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { extractPin } from '../../services/project-service/ProjectService.js';
import { isDocker } from '../../setup/index.js';
import { getErrorMessage } from 'ontime-utils';
import { obfuscate } from 'ontime-utils';
export async function getSettings(_req: Request, res: Response<Settings>) {
const settings = DataProvider.getSettings();
@@ -10,6 +10,10 @@ import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty, updateEvent } from './integration.utils.js';
import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
const action = type.toLowerCase();
@@ -43,12 +47,17 @@ const actionHandlers: Record<string, ActionHandler> = {
const data = payload[id as keyof typeof payload];
const patchEvent: Partial<OntimeEvent> & { id: string } = { id };
let shouldThrottle = false;
Object.entries(data).forEach(([property, value]) => {
if (typeof property !== 'string' || value === undefined) {
throw new Error('Invalid property or value');
}
// all custom fields keys are lowercase
const newObjectProperty = parseProperty(property.toLowerCase(), value);
const newObjectProperty = parseProperty(property, value);
const key = Object.keys(newObjectProperty)[0] as keyof OntimeEvent;
shouldThrottle = willCauseRegeneration(key) || shouldThrottle;
if (patchEvent.custom && newObjectProperty.custom) {
Object.assign(patchEvent.custom, newObjectProperty.custom);
@@ -57,8 +66,13 @@ const actionHandlers: Record<string, ActionHandler> = {
}
});
updateEvent(patchEvent);
if (shouldThrottle) {
if (throttledUpdateEvent(patchEvent)) {
return { payload: 'throttled' };
}
} else {
updateEvent(patchEvent);
}
return { payload: 'success' };
},
/* Message Service */
@@ -1,40 +1,60 @@
import { OntimeEvent, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { EndAction, OntimeEvent, TimerType, isKeyOfType, isOntimeEvent } from 'ontime-types';
import { MILLIS_PER_SECOND, maxDuration } from 'ontime-utils';
import { DataProvider } from '../classes/data-provider/DataProvider.js';
import { editEvent } from '../services/rundown-service/RundownService.js';
import { getEventWithId } from '../services/rundown-service/rundownUtils.js';
import { coerceBoolean, coerceColour, coerceNumber, coerceString } from '../utils/coerceType.js';
import { coerceBoolean, coerceColour, coerceEnum, coerceNumber, coerceString } from '../utils/coerceType.js';
const whitelistedPayload = {
/**
*
* @param {number} value time amount in seconds
* @returns {number} time in milliseconds clamped to 0 and max duration
*/
function clampDuration(value: number) {
const valueInMillis = value * MILLIS_PER_SECOND;
if (valueInMillis > maxDuration || valueInMillis < 0) {
throw new Error('Times should be from 0 to 23:59:59');
}
return valueInMillis;
}
const propertyConversion = {
title: coerceString,
note: coerceString,
cue: coerceString,
duration: (value: unknown) => Math.max(coerceNumber(value) * MILLIS_PER_SECOND, maxDuration),
isPublic: coerceBoolean,
skip: coerceBoolean,
colour: coerceColour,
custom: coerceString,
timeWarning: (value: unknown) => clampDuration(coerceNumber(value)),
timeDanger: (value: unknown) => clampDuration(coerceNumber(value)),
endAction: (value: unknown) => coerceEnum<EndAction>(value, EndAction),
timerType: (value: unknown) => coerceEnum<TimerType>(value, TimerType),
duration: (value: unknown) => clampDuration(coerceNumber(value)),
timeStart: (value: unknown) => clampDuration(coerceNumber(value)),
timeEnd: (value: unknown) => clampDuration(coerceNumber(value)),
};
export function parseProperty(property: string, value: unknown) {
if (property.startsWith('custom:')) {
const customKey = property.split(':')[1];
const customKey = property.split(':')[1].toLocaleLowerCase(); // all custom fields keys are lowercase
if (!(customKey in DataProvider.getCustomFields())) {
throw new Error(`Custom field ${customKey} not found`);
}
const parserFn = whitelistedPayload.custom;
const parserFn = propertyConversion.custom;
return { custom: { [customKey]: parserFn(value) } };
}
if (!isKeyOfType(property, whitelistedPayload)) {
if (!isKeyOfType(property, propertyConversion)) {
throw new Error(`Property ${property} not permitted`);
}
const parserFn = whitelistedPayload[property];
const parserFn = propertyConversion[property];
return { [property]: parserFn(value) };
}
@@ -51,6 +71,5 @@ export function updateEvent(patchEvent: Partial<OntimeEvent> & { id: string }) {
if (!isOntimeEvent(event)) {
throw new Error('Can only update events');
}
editEvent(patchEvent);
}
+2
View File
@@ -17,6 +17,7 @@ import {
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
clearUploadfolder,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
@@ -143,6 +144,7 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder();
populateStyles();
populateDemo();
};
@@ -1,11 +1,12 @@
import { MILLIS_PER_HOUR, dayInMs, millisToString } from 'ontime-utils';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, Playback, TimeStrategy, TimerPhase, TimerType } from 'ontime-types';
import {
getCurrent,
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
getTotalDuration,
normaliseEndTime,
skippedOutOfEvent,
@@ -1756,3 +1757,137 @@ describe('getTotalDuration()', () => {
expect(millisToString(duration)).toBe('62:00:00');
});
});
describe('getTimerPhase()', () => {
it('should be None if the timer is not running', () => {
const state = {
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
playback: Playback.Stop,
phase: TimerPhase.None,
secondaryTimer: null,
startedAt: null,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.None);
});
it('can be in overtime', () => {
const state = {
timer: {
addedTime: 0,
current: -50,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Overtime);
});
it('can be danger', () => {
const state = {
timer: {
addedTime: 0,
current: 0,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Danger);
});
it('can be warning', () => {
const state = {
timer: {
addedTime: 0,
current: 150,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Warning);
});
it('it default if the timer is playing and there is none of the above', () => {
const state = {
timer: {
addedTime: 0,
current: 250,
duration: 1000,
playback: Playback.Play,
},
eventNow: {
timeDanger: 100,
timeWarning: 200,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Default);
});
it('#1042 identifies waiting to roll', () => {
const state = {
clock: 55691050,
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 1,
offset: null,
plannedStart: 55860000,
plannedEnd: 55880000,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: 0,
expectedFinish: null,
finishedAt: null,
phase: 'none',
playback: 'roll',
secondaryTimer: 168950,
startedAt: null,
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: 55860000,
},
} as RuntimeState;
const phase = getTimerPhase(state);
expect(phase).toBe(TimerPhase.Pending);
});
});
@@ -41,6 +41,7 @@ export class AuxTimerService {
@broadcastReturn
pause() {
this.stopInterval();
return this.timer.pause(this.getTime());
}
@@ -3,9 +3,9 @@ import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } fro
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { parseTemplateNested } from './integrationUtils.js';
import { isObject } from '../../utils/varUtils.js';
import { logger } from '../../classes/Logger.js';
import { OscServer } from '../../adapters/OscAdapter.js';
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
/**
* @description Class contains logic towards outgoing OSC communications
@@ -60,27 +60,24 @@ export class OscIntegration implements IIntegration<OscSubscription, OSCSettings
}
const parsedAddress = parseTemplateNested(address, state || {});
const parsedPayload = payload ? parseTemplateNested(payload, state || {}) : undefined;
const parsedArguments = stringToOSCArgs(parsedPayload);
try {
this.emit(parsedAddress, parsedPayload);
this.emit(parsedAddress, parsedArguments);
} catch (error) {
logger.error(LogOrigin.Tx, `OSC Integration: ${error}`);
}
}
}
emit(address: string, payload?: ArgumentType) {
emit(address: string, args: ArgumentType[]) {
if (!this.oscClient) {
return;
}
//TODO: Look into using bundles
const message = new Message(address);
if (payload) {
if (isObject(payload)) {
message.append(JSON.stringify(payload));
} else {
message.append(payload);
}
}
message.append(args);
this.oscClient.send(message);
}
@@ -1,3 +1,4 @@
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
import { parseTemplateNested } from './integrationUtils.js';
describe('parseTemplateNested()', () => {
@@ -96,3 +97,61 @@ describe('parseNestedTemplate() -> resolveAliasData()', () => {
expect(easyParse).toBe('5 to testing-3 {{human.not.found}}');
});
});
describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
it('specific osc requirements', () => {
const data = {
not: {
so: {
easy: 'data with space',
empty: '',
number: 1234,
stringNumber: '1234',
},
},
};
const payloads = [
{
test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }],
},
{
test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }],
},
{
test: '"string with space and {{not.so.number}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"string with space and {{not.so.stringNumber}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
},
{
test: '"{{not.so.easy}}" 1',
expect: [
{ type: 'string', value: 'data with space' },
{ type: 'integer', value: 1 },
],
},
{
test: '"{{not.so.empty}}" 1',
expect: [
{ type: 'string', value: '' },
{ type: 'integer', value: 1 },
],
},
{
test: '',
expect: [],
},
];
payloads.forEach((payload) => {
const parsedPayload = parseTemplateNested(payload.test, data);
const parsedArguments = stringToOSCArgs(parsedPayload);
expect(parsedArguments).toStrictEqual(payload.expect);
});
});
});
@@ -90,9 +90,9 @@ export async function addEvent(eventData: PatchWithId & { after?: string }): Pro
* deletes event by its ID
* @param eventId
*/
export async function deleteEvent(eventId: string) {
export async function deleteEvent(eventIds: string[]) {
const scopedMutation = cache.mutateCache(cache.remove);
const { didMutate } = await scopedMutation({ eventId });
const { didMutate } = await scopedMutation({ eventIds });
if (didMutate === false) {
return;
@@ -102,7 +102,7 @@ export async function deleteEvent(eventId: string) {
updateRuntimeOnChange();
// notify timer and external services of change
notifyChanges({ timer: [eventId], external: true });
notifyChanges({ timer: eventIds, external: true });
}
/**
@@ -347,9 +347,22 @@ describe('remove() mutation', () => {
test('deletes an event from the rundown', () => {
const mockEvent = { id: 'mock', cue: 'mock', type: SupportedEvent.Event } as OntimeEvent;
const testRundown: OntimeRundown = [mockEvent];
const { newRundown } = remove({ eventId: mockEvent.id, persistedRundown: testRundown });
const { newRundown } = remove({ eventIds: [mockEvent.id], persistedRundown: testRundown });
expect(newRundown.length).toBe(0);
});
test('deletes multiple events from the rundown', () => {
const testRundown: OntimeRundown = [
{ type: SupportedEvent.Event, id: '1' } as OntimeEvent,
{ type: SupportedEvent.Block, id: '2' } as OntimeBlock,
{ type: SupportedEvent.Delay, id: '3' } as OntimeDelay,
{ type: SupportedEvent.Event, id: '4' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '5' } as OntimeEvent,
{ type: SupportedEvent.Event, id: '6' } as OntimeEvent,
];
const { newRundown } = remove({ eventIds: ['1', '2', '3'], persistedRundown: testRundown });
expect(newRundown.length).toBe(3);
expect(newRundown.at(0).id).toBe('4');
});
});
describe('edit() mutation', () => {
@@ -9,7 +9,7 @@ import {
OntimeRundown,
OntimeRundownEntry,
} from 'ontime-types';
import { generateId, deleteAtIndex, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { generateId, insertAtIndex, reorderArray, swapEventData, checkIsNextDay } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
@@ -263,13 +263,12 @@ export function add({ persistedRundown, atIndex, event }: AddArgs): Required<Mut
return { newRundown, newEvent, didMutate: true };
}
type RemoveArgs = MutationParams<{ eventId: string }>;
type RemoveArgs = MutationParams<{ eventIds: string[] }>;
export function remove({ persistedRundown, eventId }: RemoveArgs): MutatingReturn {
const atIndex = persistedRundown.findIndex((event) => event.id === eventId);
const newRundown = deleteAtIndex(atIndex, persistedRundown);
export function remove({ persistedRundown, eventIds }: RemoveArgs): MutatingReturn {
const newRundown = persistedRundown.filter((event) => !eventIds.includes(event.id));
return { newRundown, didMutate: atIndex !== -1 };
return { newRundown, didMutate: persistedRundown.length !== newRundown.length };
}
export function removeAll(): MutatingReturn {
@@ -100,7 +100,7 @@ export function handleCustomField(
}
/** List of event properties which do not need the rundown to be regenerated */
enum regenerateWhitelist {
export enum regenerateWhitelist {
'id',
'cue',
'title',
@@ -122,6 +122,14 @@ export function isDataStale(patch: Partial<OntimeRundownEntry>): boolean {
return Object.keys(patch).some((key) => !(key in regenerateWhitelist));
}
/**
* given a key, returns whether it is whitelisted
* @param path
*/
export function willCauseRegeneration(key: keyof OntimeEvent): boolean {
return !(key in regenerateWhitelist);
}
/**
* Given an event and a patch to that event checks whether there are actual changes to the dataset
* @param existingEvent
@@ -26,8 +26,7 @@ export function getShouldTimerUpdate(previousValue: number, currentValue: MaybeN
return false;
}
// we avoid trigger ahead since it can cause duplicate triggers
// we force the timer value to be negative because we need a ceiling reduction
const shouldUpdateTimer = millisToSeconds(-currentValue) !== millisToSeconds(-previousValue);
const shouldUpdateTimer = millisToSeconds(currentValue) !== millisToSeconds(previousValue);
return shouldUpdateTimer;
}
+61 -13
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, MaybeString, OntimeEvent, TimerType } from 'ontime-types';
import { MaybeNumber, MaybeString, OntimeEvent, Playback, TimerPhase, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { RuntimeState } from '../stores/runtimeState.js';
import { timerConfig } from '../config/config.js';
@@ -118,9 +118,8 @@ type RollTimers = {
* Finds loading information given a current rundown and time
* @param {OntimeEvent[]} rundown - List of playable events
* @param {number} timeNow - time now in ms
* @returns {{}}
*/
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTimers => {
export const getRollTimers = (rundown: OntimeEvent[], timeNow: number, currentIndex?: number | null): RollTimers => {
let nowIndex: number | null = null; // index of event now
let nowId: string | null = null; // id of event now
let publicIndex: number | null = null; // index of public event now
@@ -129,7 +128,11 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
let timeToNext: number | null = null; // counter: time for next event
let publicTimeToNext: number | null = null; // counter: time for next public event
const lastEvent = rundown[rundown.length - 1];
const hasLoaded = currentIndex !== null;
const canFilter = hasLoaded && currentIndex === rundown.length - 1;
const filteredRundown = canFilter ? rundown.slice(currentIndex) : rundown;
const lastEvent = filteredRundown.at(-1);
const lastNormalEnd = normaliseEndTime(lastEvent.timeStart, lastEvent.timeEnd);
let nextEvent: OntimeEvent | null = null;
@@ -141,7 +144,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// we are past last end
// preload first and find next
const firstEvent = rundown[0];
const firstEvent = filteredRundown.at(0);
nextIndex = 0;
nextEvent = firstEvent;
timeToNext = firstEvent.timeStart + dayInMs - timeNow;
@@ -153,11 +156,11 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// look for next public
// dev note: we feel that this is more efficient than filtering
// since the next event will likely be close to the one playing
for (const event of rundown) {
for (const event of filteredRundown) {
if (event.isPublic) {
nextPublicEvent = event;
// we need the index before this was sorted
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
break;
}
}
@@ -168,7 +171,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// keep track of the end times when looking for public
let publicTime = -1;
for (const event of rundown) {
for (const event of filteredRundown) {
// When does the event end (handle midnight)
const normalEnd = normaliseEndTime(event.timeStart, event.timeEnd);
@@ -183,12 +186,12 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
// public event might not be the one running
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (hasNotEnded && hasStarted && !nowFound) {
// event is running
currentEvent = event;
nowIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nowId = event.id;
nowFound = true;
@@ -196,7 +199,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (event.isPublic) {
publicTime = normalEnd;
currentPublicEvent = event;
publicIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
} else if (normalEnd > timeNow) {
// event will run
@@ -214,7 +217,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (nextIndex === null || timeToEventStart < timeToNext) {
timeToNext = timeToEventStart;
nextEvent = event;
nextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
nextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
if (event.isPublic) {
@@ -222,7 +225,7 @@ export const getRollTimers = (rundown: OntimeEvent[], timeNow: number): RollTime
if (publicNextIndex === null || timeToEventStart < publicTimeToNext) {
publicTimeToNext = timeToEventStart;
nextPublicEvent = event;
publicNextIndex = rundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
publicNextIndex = filteredRundown.findIndex((rundownEvent) => rundownEvent.id === event.id);
}
}
}
@@ -358,3 +361,48 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
}
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
}
/**
* Utility checks whether the playback is considered to be active
* @param state
* @returns
*/
function isPlaybackActive(state: RuntimeState): boolean {
return (
state.timer.playback === Playback.Play ||
state.timer.playback === Playback.Pause ||
state.timer.playback === Playback.Roll
);
}
/**
* Checks running timer to see which phase it currently is in
* @param state
*/
export function getTimerPhase(state: RuntimeState): TimerPhase {
if (!isPlaybackActive(state)) {
return TimerPhase.None;
}
const current = state.timer.current;
if (current === null || state.eventNow === null) {
return TimerPhase.Pending;
}
if (current < 0) {
return TimerPhase.Overtime;
}
const danger = state.eventNow.timeDanger;
if (current <= danger) {
return TimerPhase.Danger;
}
const warning = state.eventNow.timeWarning;
if (current <= warning) {
return TimerPhase.Warning;
}
return TimerPhase.Default;
}
+11 -5
View File
@@ -2,6 +2,8 @@ import { fileURLToPath } from 'url';
import path, { dirname, join } from 'path';
import fs from 'fs';
import { rm } from 'fs/promises';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -57,17 +59,13 @@ const currentDir = dirname(__dirname);
export const srcDirectory = isProduction ? currentDir : path.join(currentDir, '../');
// resolve path to external
const productionPath = path.join(srcDirectory, '../../resources/extraResources/client');
const productionPath = path.join(srcDirectory, 'client/');
const devPath = path.join(srcDirectory, '../../client/build/');
const dockerPath = path.join(srcDirectory, 'client/');
export const resolvedPath = (): string => {
if (isTest) {
return devPath;
}
if (isDocker) {
return dockerPath;
}
if (isProduction) {
return productionPath;
}
@@ -144,3 +142,11 @@ export const resolveCrashReportDirectory = getAppDataPath();
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export async function clearUploadfolder() {
try {
await rm(uploadsFolderPath, { recursive: true });
} catch (_) {
//we dont care that there was no folder
}
}
+13 -3
View File
@@ -1,4 +1,4 @@
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerState, TimerType } from 'ontime-types';
import { MaybeNumber, OntimeEvent, Playback, Runtime, TimerPhase, TimerState, TimerType } from 'ontime-types';
import { calculateDuration, dayInMs } from 'ontime-utils';
import { clock } from '../services/Clock.js';
@@ -10,6 +10,7 @@ import {
getExpectedFinish,
getRollTimers,
getRuntimeOffset,
getTimerPhase,
skippedOutOfEvent,
updateRoll,
} from '../services/timerUtils.js';
@@ -32,6 +33,7 @@ const initialTimer: TimerState = {
elapsed: null,
expectedFinish: null, // TODO: expected finish could account for midnight, we cleanup in the clients
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
@@ -300,6 +302,10 @@ export function start(state: RuntimeState = runtimeState): boolean {
state.runtime.actualStart = state.clock;
}
// update timer phase
runtimeState.timer.phase = getTimerPhase(runtimeState);
// update offset
state.runtime.offset = getRuntimeOffset(state);
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
@@ -387,6 +393,9 @@ export function update(): UpdateResult {
runtimeState.timer.duration = runtimeState.timer.current;
}
// update timer phase
runtimeState.timer.phase = getTimerPhase(runtimeState);
// update offset
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
@@ -434,10 +443,11 @@ export function update(): UpdateResult {
}
export function roll(rundown: OntimeEvent[]) {
const selectedEventIndex = runtimeState.runtime.selectedEventIndex;
clear();
runtimeState.runtime.numEvents = rundown.length;
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock);
const { nextEvent, currentEvent } = getRollTimers(rundown, runtimeState.clock, selectedEventIndex);
if (currentEvent) {
// there is something running, load
@@ -1,4 +1,4 @@
import { coerceColour } from '../coerceType.js';
import { coerceColour, coerceEnum } from '../coerceType.js';
describe('parses a colour string that is', () => {
it('valid hex', () => {
@@ -19,3 +19,21 @@ describe('parses a colour string that is', () => {
expect(() => coerceColour(5)).toThrowError(Error('Invalid colour value received'));
});
});
describe('match a string to an enum that is', () => {
enum testEnum {
'abc',
'def',
'ghi',
}
it('valid key', () => {
const key = coerceEnum<testEnum>('abc', testEnum);
expect(key).toBe('abc');
});
it('invalid key', () => {
expect(() => coerceEnum('123', testEnum)).toThrowError(Error('Invalid value received'));
});
it('invalid type', () => {
expect(() => coerceEnum(123, testEnum)).toThrowError(Error('Invalid value received'));
});
});
@@ -0,0 +1,74 @@
import { stringToOSCArgs } from '../oscArgParser.js';
describe('test stringToOSCArgs()', () => {
it('all types', () => {
const test = 'test 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = undefined;
const expected = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = '';
const expected = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep other types in strings', () => {
const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'string', value: '1111' },
{ type: 'string', value: '0.1111' },
{ type: 'string', value: 'TRUE' },
{ type: 'string', value: 'FALSE' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces in quoted strings', () => {
const test = '"test space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces escaped quotes', () => {
const test = '"test \\" space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test " space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('2 spaces', () => {
const test = '1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'T', value: true },
{ type: 'F', value: false },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
@@ -110,7 +110,7 @@ describe('sanitiseCustomFields()', () => {
it('label can not be empty', () => {
const customFields: CustomFields = {
['']: { label: '', type: 'string', colour: 'red' },
'': { label: '', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
@@ -133,7 +133,7 @@ describe('sanitiseCustomFields()', () => {
test: { label: 'New Name', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
['new name']: { label: 'New Name', type: 'string', colour: 'red' },
'new name': { label: 'New Name', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
+13
View File
@@ -1,5 +1,18 @@
import { isColourHex } from 'ontime-utils';
/**
* @description Converts a value to an item in the provided enume.
* @param {unknown} value - Value to be converted.
* @returns {T} - The converted value as key of the enum.
* @throws {Error} Throws an error value is not found in the enum.
*/
export function coerceEnum<T>(value: unknown, list: object): T {
if (typeof value !== 'string' || !(value in list)) {
throw new Error('Invalid value received');
}
return value as T;
}
//TODO: write tests
/**
* @description Converts a value to a string if possible, throws otherwise
+37
View File
@@ -0,0 +1,37 @@
import { Argument } from 'node-osc';
import { splitWhitespace } from 'ontime-utils';
export function stringToOSCArgs(argsString: string | undefined): Argument[] {
if (typeof argsString === 'undefined' || argsString === '') {
return new Array<Argument>();
}
const matches = splitWhitespace(argsString);
const parsedArguments: Argument[] = matches.map((argString: string) => {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
if (!Number.isNaN(argAsNum)) {
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
}
if (argString.startsWith('"') && argString.endsWith('"')) {
// NOTE: "quoted string"
return { type: 'string', value: argString.substring(1, argString.length - 1) };
}
if (argString === 'TRUE') {
// NOTE: Boolean true
return { type: 'T', value: true };
}
if (argString === 'FALSE') {
// NOTE: Boolean false
return { type: 'F', value: false };
}
// NOTE: string
return { type: 'string', value: argString };
});
return parsedArguments;
}
+2 -1
View File
@@ -22,11 +22,12 @@ export function throttle<T extends any[], U>(cb: (...args: T) => U, delay: numbe
return (...args: T) => {
if (shouldWait) {
waitingArgs = args;
return;
return true;
}
cb(...args);
shouldWait = true;
setTimeout(timeoutFunc, delay);
return false;
};
}
+6 -77
View File
@@ -1,4 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from 'ontime-utils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
export const timeFormat = 'HH:mm';
@@ -10,92 +10,21 @@ export const timeFormatSeconds = 'HH:mm:ss';
* @returns {number} - time in milliseconds
*/
export const dateToMillis = (date: Date): number => {
export function dateToMillis(date: Date): number {
// TODO: Use UTC
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
return h * MILLIS_PER_HOUR + m * MILLIS_PER_MINUTE + s * MILLIS_PER_SECOND;
};
/**
* @description safe parse string to int, copied from client code
* @param valueAsString
* @return {number}
*/
const parse = (valueAsString: string): number => {
const parsed = parseInt(valueAsString, 10);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
};
const stripAMPM = (value: string) => {
const lowerValue = value.toLowerCase();
if (lowerValue.endsWith('am')) {
return { sansPostfix: lowerValue.substring(0, lowerValue.length - 2), pastNoon: false };
} else if (lowerValue.endsWith('pm')) {
return { sansPostfix: lowerValue.substring(0, lowerValue.length - 2), pastNoon: true };
} else {
return { sansPostfix: lowerValue, pastNoon: false };
}
};
/**
* @description Parses a time string to millis, copied from client code
* @param {string} value - time string
* @returns {number} - time string in millis
*/
export const forgivingStringToMillis = (value: string): number => {
let millis = 0;
// check for AM/PM indicators
const { sansPostfix, pastNoon } = stripAMPM(value);
//if past noon indicated add 12 hours
if (pastNoon) {
millis = MILLIS_PER_HOUR * 12;
}
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = sansPostfix.split(separatorRegex);
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
millis += parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) {
// if string has one section,
// could be a complete string like 121010 - 12:10:10
if (first.length === 6) {
const hours = first.substring(0, 2);
const minutes = first.substring(2, 4);
const seconds = first.substring(4);
millis += parse(hours) * MILLIS_PER_HOUR;
millis += parse(minutes) * MILLIS_PER_MINUTE;
millis += parse(seconds) * MILLIS_PER_SECOND;
} else {
// otherwise lets treat as [minutes]
millis += parse(first) * MILLIS_PER_MINUTE;
}
}
if (first != null && second != null && third == null) {
// if string has two sections treat as [hours] [minutes]
millis += parse(first) * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
}
return millis;
};
}
/**
* @description Parses an excel date using the correct parser
* @param {string} excelDate
* @returns {number} - time in milliseconds
*/
export const parseExcelDate = (excelDate: unknown): number => {
export function parseExcelDate(excelDate: unknown): number {
if (excelDate instanceof Date) {
return dateToMillis(excelDate);
}
@@ -107,7 +36,7 @@ export const parseExcelDate = (excelDate: unknown): number => {
return dateToMillis(date);
}
}
return forgivingStringToMillis(excelDate);
return parseUserTime(excelDate);
}
// if the user uses a number value eg. 15, excel could format the cell as number
@@ -116,4 +45,4 @@ export const parseExcelDate = (excelDate: unknown): number => {
}
return 0;
};
}
@@ -0,0 +1,144 @@
import { test, expect } from '@playwright/test';
test('Copy Past', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create event
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByLabel('Cue', { exact: true }).click();
await page.getByLabel('Cue', { exact: true }).fill('4');
await page.getByLabel('Cue', { exact: true }).press('Enter');
await page.getByTestId('entry-1').click();
await page.getByTestId('block__title').click();
await page.getByTestId('block__title').fill('test');
await page.getByTestId('block__title').press('Enter');
//copy past below
await page.locator('div').filter({ hasText: /^4$/ }).click();
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+c');
await page.locator('div').filter({ hasText: /^4$/ }).press('Control+v');
//assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('5');
//copy past above
await page.locator('div').filter({ hasText: /^5$/ }).click();
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+c');
await page.locator('div').filter({ hasText: /^5$/ }).press('Control+Shift+v');
//assert
await expect(page.getByTestId('entry-2')).toBeVisible();
await expect(page.getByTestId('entry-2').getByTestId('block__title')).toHaveValue('test');
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('4.1');
});
test('Move', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create events
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
//copy move down
await page.getByTestId('entry-1').locator('#event-block').getByText('1').click();
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Control+ArrowDown');
await expect(page.getByTestId('entry-2').locator('#event-block')).toContainText('1');
//copy move up
await page.getByTestId('entry-3').locator('#event-block').getByText('3').click();
await page.getByTestId('entry-3').locator('#event-block div').filter({ hasText: '3' }).press('Alt+Control+ArrowUp');
await page.getByTestId('entry-2').locator('div').filter({ hasText: /^3$/ }).press('Alt+Control+ArrowUp');
await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('3');
});
test('Add block', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create events
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByTestId('block__title').press('Escape');
//add block below
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+B');
await expect(page.getByPlaceholder('Block title')).toBeVisible();
//add block above
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+B');
await expect(page.getByTestId('entry-0').getByTestId('block__title')).toBeVisible();
});
test('Add delay', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create events
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByTestId('block__title').press('Escape');
//add delay below
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+D');
await expect(page.getByTestId('delay-input')).toBeVisible();
//add delay above
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+D');
await expect(page.getByTestId('entry-0').getByTestId('delay-input')).toBeVisible();
});
test('Add event', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create events
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByTestId('entry-1').click();
await page.getByTestId('block__title').press('Escape');
//add event below
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+E');
await expect(page.getByTestId('entry-2').locator('#event-block').getByText('2')).toBeVisible();
//add event above
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Shift+E');
await expect(page.getByTestId('entry-1').locator('#event-block')).toContainText('0.1');
});
test('Delete event', async ({ page }) => {
await page.goto('http://localhost:4001/rundown');
// clear rundown
await page.goto('http://localhost:4001/rundown');
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
//create event
await page.getByRole('button', { name: 'Create Event' }).click();
//delete event
await page.locator('#event-block div').filter({ hasText: '1' }).click();
await page.getByTestId('entry-1').locator('#event-block div').filter({ hasText: '1' }).press('Alt+Backspace');
await expect(page.getByRole('button', { name: 'Create Event' })).toBeVisible();
});
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.0.3",
"version": "3.1.1",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -29,7 +29,6 @@
"build": "turbo run build",
"build:local": "turbo run build:local",
"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-mac": "turbo run dist-mac",
@@ -4,7 +4,7 @@ export type OscSubscription = {
id: string;
cycle: TimerLifeCycleKey;
address: string;
payload: string;
payload: string; // TODO: we should be using arguments to keep in line with protocol language
enabled: boolean;
};
@@ -1,6 +1,15 @@
import type { MaybeNumber } from '../../index.js';
import type { Playback } from './Playback.type.js';
export enum TimerPhase {
None = 'none',
Default = 'default',
Warning = 'warning',
Danger = 'danger',
Overtime = 'overtime',
Pending = 'pending', // used for waiting to roll
}
export type TimerState = {
addedTime: number; // time added by user, can be negative
current: MaybeNumber; // running countdown
@@ -8,6 +17,7 @@ export type TimerState = {
elapsed: MaybeNumber; // elapsed time in current timer
expectedFinish: MaybeNumber; // time we expect timer to finish
finishedAt: MaybeNumber; // only if timer has already finished
phase: TimerPhase;
playback: Playback;
secondaryTimer: MaybeNumber; // used for roll mode
startedAt: MaybeNumber; // only if timer has already started
+1 -1
View File
@@ -60,7 +60,7 @@ export type { Message, TimerMessage, MessageState } from './definitions/runtime/
export type { Runtime } from './definitions/runtime/Runtime.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export type { TimerState } from './definitions/runtime/TimerState.type.js';
export { type TimerState, TimerPhase } from './definitions/runtime/TimerState.type.js';
// ---> Extra Timer
export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './definitions/runtime/AuxTimer.type.js';
+2
View File
@@ -45,8 +45,10 @@ export {
removeSeconds,
removeTrailingZero,
} from './src/date-utils/timeFormatting.js';
export { parseUserTime } from './src/date-utils/parseUserTime.js';
export { isAlphanumeric } from './src/regex-utils/isAlphanumeric.js';
export { isColourHex } from './src/regex-utils/isColourHex.js';
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
// helpers from externals
export { deepmerge } from './src/externals/deepmerge.js';
@@ -34,6 +34,12 @@ export function deleteAtIndex<T>(index: number, array: T[]) {
return array.filter((_, i) => i !== index);
}
/**
* Reorders two objects in an array
* @param array
* @param fromIndex
* @param toIndex
*/
export function reorderArray<T>(array: T[], fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) {
return array; // No change needed, return the original array
@@ -1,4 +1,4 @@
type MaybeNumber = number | null;
import type { MaybeNumber } from 'ontime-types';
export const MILLIS_PER_SECOND = 1000;
export const MILLIS_PER_MINUTE = 1000 * 60;
@@ -7,26 +7,60 @@ export const MILLIS_PER_HOUR = 1000 * 60 * 60;
export const dayInMs = 86400000;
export const maxDuration = dayInMs - MILLIS_PER_SECOND;
function convertMillis(millis: MaybeNumber, conversion: number) {
/**
* Utility converts milliseconds to a specific unit
* @param millis
* @param conversion
* @returns
*/
function convertMillis(millis: MaybeNumber, conversion: number): number {
if (!millis) {
return 0;
}
// for negative times, we want to round up
if (millis < 0) {
Math.ceil(millis / conversion);
}
return Math.floor(millis / conversion);
}
export function millisToSeconds(millis: MaybeNumber) {
/**
* Converts value in milliseconds to seconds
* @param millis
* @returns
*/
export function millisToSeconds(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_SECOND);
}
export function millisToMinutes(millis: MaybeNumber) {
/**
* Converts value in milliseconds to minutes
* @param millis
* @returns
*/
export function millisToMinutes(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_MINUTE);
}
export function millisToHours(millis: MaybeNumber) {
/**
* Converts value in milliseconds to hours
* @param millis
* @returns
*/
export function millisToHours(millis: MaybeNumber): number {
return convertMillis(millis, MILLIS_PER_HOUR);
}
/**
* Converts value in seconds to minutes
* @param seconds
* @returns
*/
export function secondsToMinutes(seconds: number): number {
return Math.floor(seconds / 60);
}
/**
* Converts value in seconds to hours
* @param seconds
* @returns
*/
export function secondsToHours(seconds: number): number {
return Math.floor(seconds / 3600);
}
@@ -0,0 +1,333 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from './conversionUtils';
import { parseUserTime } from './parseUserTime';
describe('test parseUserTime()', () => {
describe('function handles time with no separators', () => {
const testData = [
{ value: '', expect: 0 },
{ value: '0', expect: 0 },
{ value: '-0', expect: 0 },
{ value: '1', expect: MILLIS_PER_MINUTE },
{ value: '-1', expect: MILLIS_PER_MINUTE },
{ value: '0h0m0s', expect: 0 },
{ value: '0h0m1s', expect: MILLIS_PER_SECOND },
{ value: '0h1m0s', expect: MILLIS_PER_MINUTE },
{ value: '1h0m0s', expect: MILLIS_PER_HOUR },
{ value: '23h0m0s', expect: 23 * MILLIS_PER_HOUR },
{ value: '12h12m12s', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '12H12M12S', expect: 12 * MILLIS_PER_SECOND + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_HOUR },
{ value: '2m', expect: 2 * MILLIS_PER_MINUTE },
{ value: '1h5s', expect: MILLIS_PER_HOUR + 5 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof parseUserTime(s.value)).toBe('number');
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.1.1', expect: MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.1.1', expect: 12 * MILLIS_PER_HOUR + MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.1', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + MILLIS_PER_SECOND },
{ value: '12.55.40', expect: 12 * MILLIS_PER_HOUR + 55 * MILLIS_PER_MINUTE + 40 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('#33 separators are parsed according to doc examples ', () => {
const ts = [
{ value: '0.1', expect: MILLIS_PER_MINUTE },
{ value: '0 1', expect: MILLIS_PER_MINUTE },
{ value: '0:1', expect: MILLIS_PER_MINUTE },
{ value: '0,1', expect: MILLIS_PER_MINUTE },
{ value: '2.2.2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2 2 2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2:2:2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '2,2,2', expect: 2 * MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 2 * MILLIS_PER_SECOND },
{ value: '10:', expect: 10 * MILLIS_PER_HOUR },
{ value: ':10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10', expect: 10 * MILLIS_PER_MINUTE },
{ value: '120', expect: MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE },
{ value: '90m', expect: 90 * MILLIS_PER_MINUTE },
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.2.3', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('parses time strings', () => {
const ts = [
{ value: '1h2m3s', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND },
{ value: '1h3s', expect: MILLIS_PER_HOUR + 3 * MILLIS_PER_SECOND },
{ value: '1h2m', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '10h', expect: 10 * MILLIS_PER_HOUR },
{ value: '10m', expect: 10 * MILLIS_PER_MINUTE },
{ value: '10s', expect: 10 * MILLIS_PER_SECOND },
{ value: '120h', expect: 120 * MILLIS_PER_HOUR },
{ value: '120m', expect: 120 * MILLIS_PER_MINUTE },
{ value: '120s', expect: 120 * MILLIS_PER_SECOND },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('handles am/pm', () => {
const ampm = [
{ value: '1am', expect: 1 * MILLIS_PER_HOUR },
{ value: '1pm', expect: (12 + 1) * MILLIS_PER_HOUR },
{ value: '1 am', expect: 1 * MILLIS_PER_HOUR },
{ value: '1 pm', expect: (12 + 1) * MILLIS_PER_HOUR },
{ value: '1AM', expect: 1 * MILLIS_PER_HOUR },
{ value: '1PM', expect: (12 + 1) * MILLIS_PER_HOUR },
{ value: '9am', expect: 9 * MILLIS_PER_HOUR },
{ value: '9pm', expect: (12 + 9) * MILLIS_PER_HOUR },
{ value: '10am', expect: 10 * MILLIS_PER_HOUR },
{ value: '10pm', expect: (12 + 10) * MILLIS_PER_HOUR },
{ value: '10 am', expect: 10 * MILLIS_PER_HOUR },
{ value: '10 pm', expect: (12 + 10) * MILLIS_PER_HOUR },
{ value: '10AM', expect: 10 * MILLIS_PER_HOUR },
{ value: '10PM', expect: (12 + 10) * MILLIS_PER_HOUR },
{ value: '12am', expect: 0 * MILLIS_PER_HOUR },
{ value: '12pm', expect: 12 * MILLIS_PER_HOUR },
{ value: '1:10am', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10pm', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10 am', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10 pm', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10AM', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10PM', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '9:10pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10am', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10pm', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10 am', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10 pm', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10AM', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '10:10PM', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '12:10am', expect: 0 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '12:10pm', expect: 12 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '1:10:10am', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '1:10:10pm', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '1:10:10 am', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '1:10:10 pm', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '1:10:10AM', expect: 1 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '1:10:10PM', expect: (12 + 1) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '9:10:10am', expect: 9 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '9:10:10pm', expect: (12 + 9) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10am', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10pm', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10 am', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10 pm', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10AM', expect: 10 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '10:10:10PM', expect: (12 + 10) * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '12:10:10am', expect: 0 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
{ value: '12:10:10pm', expect: 12 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE + 10 * MILLIS_PER_SECOND },
];
for (const s of ampm) {
it(`handles ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('it infers separators when non existent', () => {
const testCases = [
{ value: '1', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '12', expect: 12 * MILLIS_PER_MINUTE }, // 00:12:00
{ value: '123', expect: MILLIS_PER_HOUR + 23 * MILLIS_PER_MINUTE }, // 01:23:00
{ value: '1234', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE }, // 12:34:00
{ value: '12345', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 5 * MILLIS_PER_SECOND }, // 12:34:05
{ value: '123456', expect: 12 * MILLIS_PER_HOUR + 34 * MILLIS_PER_MINUTE + 56 * MILLIS_PER_SECOND }, // 12:34:56
];
for (const s of testCases) {
it(`handles basic strings digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const sixDigits = [
{ value: '000000', expect: 0 }, // 00:00:00
{ value: '000001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '000100', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230000', expect: MILLIS_PER_HOUR * 23 }, // 23:00:00
{ value: '121212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 12 * MILLIS_PER_SECOND }, // 12:12:12
];
for (const s of sixDigits) {
it(`handles string with 6 digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const fiveDigits = [
{ value: '00000', expect: 0 },
{ value: '00001', expect: MILLIS_PER_SECOND }, // 00:00:01
{ value: '00010', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '00100', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '01000', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '10000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '23000', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '12121', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE + 1 * MILLIS_PER_SECOND }, // 12:12:01
];
for (const s of fiveDigits) {
it(`handles string with 5 digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const fourDigits = [
{ value: '0000', expect: 0 },
{ value: '0001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '0010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '0100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '1000', expect: 10 * MILLIS_PER_HOUR }, // 10:00:00
{ value: '2300', expect: 23 * MILLIS_PER_HOUR }, // 23:00:00
{ value: '1212', expect: 12 * MILLIS_PER_HOUR + 12 * MILLIS_PER_MINUTE }, // 12:12:00
];
for (const s of fourDigits) {
it(`handles string with 4 digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const threeDigits = [
{ value: '000', expect: 0 },
{ value: '001', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '010', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '100', expect: MILLIS_PER_HOUR }, // 01:00:00
{ value: '230', expect: 2 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE }, // 02:30:00
{ value: '121', expect: MILLIS_PER_HOUR + 21 * MILLIS_PER_MINUTE }, // 01:21:00
];
for (const s of threeDigits) {
it(`handles string with 3 digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const twoDigits = [
{ value: '00', expect: 0 },
{ value: '01', expect: MILLIS_PER_MINUTE }, // 00:01:00
{ value: '10', expect: 10 * MILLIS_PER_MINUTE }, // 00:10:00
{ value: '23', expect: 23 * MILLIS_PER_MINUTE }, // 00:23:00
];
for (const s of twoDigits) {
it(`handles string with 2 digits: ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
const singleDigit = [...Array(10).keys()];
for (const s of singleDigit) {
it(`handles string with a single digits ${s}`, () => {
expect(parseUserTime(`${s}`)).toBe(s * 1000 * 60);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '99', expect: 99 * MILLIS_PER_MINUTE },
{ value: '1.39.0', expect: 99 * MILLIS_PER_MINUTE },
// seconds overflow
{ value: '0.0.120', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.2.0', expect: 120 * MILLIS_PER_SECOND },
{ value: '0.0.99', expect: 99 * MILLIS_PER_SECOND },
{ value: '0.1.39', expect: 99 * MILLIS_PER_SECOND },
// hours overflow
{ value: '25.0.0', expect: 25 * MILLIS_PER_HOUR },
// hours overflow
{ value: '50.0.0', expect: 50 * MILLIS_PER_HOUR },
];
for (const s of ts) {
it(`handles ${s.value} to the left`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('test fillLeft', () => {
describe('function handles separators', () => {
const testData = [
{ value: '1:2:3:10', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE + 3 * MILLIS_PER_SECOND }, // 01:02:03
{ value: '2,10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2.10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
{ value: '2 10', expect: 2 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE },
];
for (const s of testData) {
it(`handles ${s.value}`, () => {
expect(typeof parseUserTime(s.value)).toBe('number');
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('parses strings correctly', () => {
const ts = [
{ value: '1.2', expect: MILLIS_PER_HOUR + 2 * MILLIS_PER_MINUTE },
{ value: '1.70', expect: MILLIS_PER_HOUR + 70 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
describe('handles overflows', () => {
const ts = [
// minutes overflow
{ value: '0.120', expect: 120 * MILLIS_PER_MINUTE },
{ value: '0.99', expect: 99 * MILLIS_PER_MINUTE },
];
for (const s of ts) {
it(`handles ${s.value}`, () => {
expect(parseUserTime(s.value)).toBe(s.expect);
});
}
});
});
});
@@ -0,0 +1,170 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND } from './conversionUtils.js';
/**
* @description Parses a user given time string to a value in milliseconds
* @param {string} value - time string
* @returns {number} - value in milliseconds
*/
export function parseUserTime(value: string): number {
const { isAM, isPM, value: parsingValue } = checkAmPm(value.toLowerCase());
const maybeMillisFromMatchers = checkMatchers(parsingValue);
if (maybeMillisFromMatchers !== null) {
return maybeMillisFromMatchers;
}
// split string at known separators : , .
const separatorRegex = /[\s,:.]+/;
const [first, second, third] = parsingValue.split(separatorRegex);
let millis = 0;
let addTwelve = isPM;
if (first != null && second != null && third != null) {
// if string has three sections, treat as [hours] [minutes] [seconds]
let hours = parse(first);
if (hours === 12) {
if (isAM) {
hours = 0;
}
addTwelve = false;
}
millis = hours * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
millis += parse(third) * MILLIS_PER_SECOND;
} else if (first != null && second == null && third == null) {
// we only have one section, infer separators
const { inferredMillis, addAM } = inferSeparators(first, isAM, isPM);
millis = inferredMillis;
addTwelve = addAM < 12 && isPM;
} else if (first != null && second != null && third == null) {
// if string has two sections, treat as [hours] [minutes]
let hours = parse(first);
if (hours === 12) {
if (isAM) {
hours = 0;
}
addTwelve = false;
}
millis = hours * MILLIS_PER_HOUR;
millis += parse(second) * MILLIS_PER_MINUTE;
}
if (addTwelve) {
millis += 12 * MILLIS_PER_HOUR;
}
return millis;
}
/**
* @description Utility function to infer separators from a whole string
* @param {string} value
* @param {boolean} isAM
* @param {boolean} isPM
*/
function inferSeparators(value: string, isAM: boolean, isPM: boolean) {
const length = value.length;
let inferredMillis = 0;
let addAM = 0;
if (length === 1) {
if (isPM || isAM) {
inferredMillis = parse(value) * MILLIS_PER_HOUR;
if (isAM) {
// this ensures we dont add 12 hours in the end
addAM = inferredMillis;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 2) {
if (isPM || isAM) {
if (value === '12' && isAM) {
inferredMillis = 0;
} else {
inferredMillis = parse(value) * MILLIS_PER_HOUR;
}
if (isAM || value === '12') {
// this ensures we dont add 12 hours in the end
addAM = 12;
}
} else {
inferredMillis = parse(value) * MILLIS_PER_MINUTE;
}
} else if (length === 3) {
inferredMillis = parse(value[0]) * MILLIS_PER_HOUR + parse(value.substring(1)) * MILLIS_PER_MINUTE;
} else if (length === 4) {
inferredMillis = parse(value.substring(0, 2)) * MILLIS_PER_HOUR + parse(value.substring(2)) * MILLIS_PER_MINUTE;
} else if (length >= 5) {
const hours = parse(value.substring(0, 2));
const minutes = parse(value.substring(2, 4));
const seconds = parse(value.substring(4));
inferredMillis = hours * MILLIS_PER_HOUR + minutes * MILLIS_PER_MINUTE + seconds * MILLIS_PER_SECOND;
}
return { inferredMillis, addAM };
}
/**
* @description Utility function to check if a string contain h / m / s indicators
* @param {string} value
*/
function checkMatchers(value: string): number | null {
const hoursMatch = /(\d+)h/i.exec(value);
const hoursMatchValue = hoursMatch ? parse(hoursMatch[1]) : 0;
const minutesMatch = /(\d+)m/i.exec(value);
const minutesMatchValue = minutesMatch ? parse(minutesMatch[1]) : 0;
const secondsMatch = /(\d+)s/i.exec(value);
const secondsMatchValue = secondsMatch ? parse(secondsMatch[1]) : 0;
if (hoursMatchValue > 0 || minutesMatchValue > 0 || secondsMatchValue > 0) {
return (
hoursMatchValue * MILLIS_PER_HOUR + minutesMatchValue * MILLIS_PER_MINUTE + secondsMatchValue * MILLIS_PER_SECOND
);
}
return null;
}
/**
* @description Utility function to check if a string contain am/pm indicators
* @description expects the value to be lowercased
* @param {string} value
* @returns {Object}
* @returns {boolean} result.isAM - Whether we parsed an AM indicator
* @returns {boolean} result.isPM - Whether we parsed a PM indicator
* @returns {string} result.value - The original string with AM/PM indicator removed
*/
function checkAmPm(value: string): { isAM: boolean; isPM: boolean; value: string } {
if (value.endsWith('pm')) {
return { isAM: false, isPM: true, value: value.slice(0, -2) };
}
if (value.endsWith('p')) {
return { isAM: false, isPM: true, value: value.slice(0, -1) };
}
if (value.endsWith('am')) {
return { isAM: true, isPM: false, value: value.slice(0, -2) };
}
if (value.endsWith('a')) {
return { isAM: true, isPM: false, value: value.slice(0, -1) };
}
return { isAM: false, isPM: false, value };
}
/**
* @description safe parse string to int
* @param {string} valueAsString
* @return {number}
*/
function parse(valueAsString: string): number {
const parsed = Number(valueAsString);
if (isNaN(parsed)) {
return 0;
}
return Math.abs(parsed);
}
@@ -11,17 +11,29 @@ describe('millisToString()', () => {
expect(millisToString(0)).toBe('00:00:00');
});
it('shows negative timers', () => {
test('negative times are rounded up', () => {
const testScenarios = [
{ millis: -300, expected: '-00:00:00' },
{ millis: 300, expected: '00:00:00' },
{ millis: -300, expected: '-00:00:01' },
{ millis: 1000, expected: '00:00:01' },
{ millis: -1000, expected: '-00:00:01' },
{ millis: -1500, expected: '-00:00:01' },
{ millis: 1500, expected: '00:00:01' },
{ millis: -1500, expected: '-00:00:02' },
{ millis: 60000 - 1, expected: '00:00:59' },
{ millis: -(60000 - 1), expected: '-00:01:00' },
{ millis: 60000, expected: '00:01:00' },
{ millis: -60000, expected: '-00:01:00' },
{ millis: 600000, expected: '00:10:00' },
{ millis: -600000, expected: '-00:10:00' },
{ millis: 3600000, expected: '01:00:00' },
{ millis: -3600000, expected: '-01:00:00' },
{ millis: 36000000, expected: '10:00:00' },
{ millis: -36000000, expected: '-10:00:00' },
{ millis: 86399000, expected: '23:59:59' },
{ millis: -86399000, expected: '-23:59:59' },
{ millis: 86400000, expected: '24:00:00' },
{ millis: -86400000, expected: '-24:00:00' },
{ millis: 86401000, expected: '24:00:01' },
{ millis: -86401000, expected: '-24:00:01' },
];
+23 -22
View File
@@ -1,6 +1,6 @@
import type { MaybeNumber } from 'ontime-types';
import { millisToHours, millisToMinutes, millisToSeconds } from './conversionUtils.js';
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
function pad(val: number): string {
return String(val).padStart(2, '0');
@@ -21,12 +21,13 @@ export function millisToString(millis?: MaybeNumber, options?: FormatOptions): s
return options?.fallback ?? '...';
}
const absoluteMillis = Math.abs(millis);
const seconds = millisToSeconds(absoluteMillis) % 60;
const minutes = millisToMinutes(absoluteMillis) % 60;
const hours = millisToHours(absoluteMillis);
const isNegative = millis < 0;
const totalSeconds = Math.abs(millisToSeconds(millis));
const seconds = totalSeconds % 60;
const minutes = secondsToMinutes(totalSeconds) % 60;
const hours = secondsToHours(totalSeconds);
return `${isNegative ? '-' : ''}${[hours, minutes, seconds].map(pad).join(':')}`;
}
@@ -71,14 +72,14 @@ export function removeSeconds(timer: string): string {
/**
* Formats a given date into a custom string format based on UTC time.
*
*
* @param millis - The number of milliseconds.
* @param format - A string specifying the desired output format.
* @param format - A string specifying the desired output format.
* For example, 'ss' will format the millis as '07' seconds.
*
*
* @returns The formatted date as a string according to the provided `format` string.
* If input `millis` is smaller than zero, it returns undefined.
*
*
*/
export function formatFromMillis(millis: number, format: string): string | undefined {
if (millis < 0) {
@@ -94,21 +95,21 @@ export function formatFromMillis(millis: number, format: string): string | undef
const secondPadded = date.getUTCSeconds().toString().padStart(2, '0');
const second = date.getUTCSeconds().toString();
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
const hour12 = ((date.getUTCHours() % 12) || 12).toString();
const hour12 = (date.getUTCHours() % 12 || 12).toString();
const hour12Padded = hour12.padStart(2, '0');
const amPm = date.getUTCHours() >= 12 ? 'PM' : 'AM';
const replacements: Record<string, string> = {
'HH': hour24Padded,
'H': hour24,
'hh': hour12Padded,
'h': hour12,
'mm': minutePadded,
'm': minute,
'ss': secondPadded,
's': second,
'S': milliseconds,
'a': amPm
HH: hour24Padded,
H: hour24,
hh: hour12Padded,
h: hour12,
mm: minutePadded,
m: minute,
ss: secondPadded,
s: second,
S: milliseconds,
a: amPm,
};
return applyReplacements(format, replacements);
@@ -123,7 +124,7 @@ export function formatFromMillis(millis: number, format: string): string | undef
*/
function applyReplacements(template: string, replacements: Record<string, string>): string {
return Object.keys(replacements).reduce((result, token) => {
const regex = new RegExp(`\\b${token}\\b`, 'g');
return result.replace(regex, replacements[token]);
const regex = new RegExp(`\\b${token}\\b`, 'g');
return result.replace(regex, replacements[token]);
}, template);
}
@@ -0,0 +1,53 @@
import { splitWhitespace } from './splitWhitespace';
describe('test splitWhitespace() function', () => {
it('empty string', () => {
const test = '';
expect(splitWhitespace(test)).toStrictEqual(null);
});
it('just space', () => {
const test = ' ';
expect(splitWhitespace(test)).toStrictEqual(null);
});
it('1 item', () => {
const test = 'test';
expect(splitWhitespace(test)).toStrictEqual(['test']);
});
it('2 items', () => {
const test = 'test test';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test']);
});
it('2 items and quoted string', () => {
const test = 'test test "more test"';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
});
it('2 sapces', () => {
const test = 'test test "more test"';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
});
it('quotes without spaces', () => {
const test = 'test test "moreTest"';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"moreTest"']);
});
it('escaped quotes', () => {
const test = 'test test "more \\" test"';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more " test"']);
});
it('missing end quotes', () => {
const test = 'test test "more test';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test']);
});
it('missing start quotes', () => {
const test = 'test test more test"';
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', 'more', 'test"']);
});
});
@@ -0,0 +1,37 @@
const splitRegex = /\\?.|^$/g;
/**
* adapted from {@link https://stackoverflow.com/questions/4031900/split-a-string-by-whitespace-keeping-quoted-segments-allowing-escaped-quotes this}
* @param str string to split
* @returns
*/
export const splitWhitespace = (str: string, keepQuotes = true): null | string[] => {
const match = str.match(splitRegex);
if (!match || match[0] == '') {
return null;
}
const array = match
.reduce(
(accumulator, current) => {
if (current === '"') {
accumulator.inQuotes ^= 1;
if (keepQuotes) {
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
}
} else if (!accumulator.inQuotes && current === ' ') {
accumulator.array.push('');
} else {
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
}
return accumulator;
},
{ array: [''], inQuotes: 0 },
)
.array.filter((value) => value != '');
if (!array.length) {
return null;
}
return array;
};
+35 -62
View File
@@ -129,8 +129,8 @@ importers:
specifier: ^3.1.1
version: 3.1.1
zustand:
specifier: ^4.5.0
version: 4.5.0(@types/react@18.0.26)(react@18.2.0)
specifier: ^4.5.2
version: 4.5.2(@types/react@18.0.26)(react@18.2.0)
devDependencies:
'@sentry/vite-plugin':
specifier: ^2.16.1
@@ -229,8 +229,8 @@ importers:
apps/electron:
devDependencies:
electron:
specifier: ^28.0.0
version: 28.0.0
specifier: ^28.3.3
version: 28.3.3
electron-builder:
specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3)
@@ -249,9 +249,6 @@ importers:
'@googleapis/sheets':
specifier: ^5.0.5
version: 5.0.5
body-parser:
specifier: ^1.20.0
version: 1.20.1
cors:
specifier: ^2.8.5
version: 2.8.5
@@ -291,12 +288,6 @@ importers:
ontime-utils:
specifier: workspace:*
version: link:../../packages/utils
passport:
specifier: ^0.6.0
version: 0.6.0
passport-local:
specifier: ~1.0.0
version: 1.0.0
steno:
specifier: ^3.1.0
version: 3.2.0
@@ -465,7 +456,7 @@ packages:
engines: {node: '>=6.9.0'}
dependencies:
'@babel/highlight': 7.24.5
picocolors: 1.0.0
picocolors: 1.0.1
dev: true
/@babel/compat-data@7.23.5:
@@ -628,7 +619,7 @@ packages:
'@babel/helper-validator-identifier': 7.24.5
chalk: 2.4.2
js-tokens: 4.0.0
picocolors: 1.0.0
picocolors: 1.0.1
dev: true
/@babel/parser@7.23.6:
@@ -3359,7 +3350,7 @@ packages:
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
'@types/connect': 3.4.35
'@types/node': 18.11.18
'@types/node': 18.19.3
dev: true
/@types/cacheable-request@6.0.3:
@@ -3412,7 +3403,7 @@ packages:
/@types/express-serve-static-core@4.17.33:
resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==}
dependencies:
'@types/node': 18.11.18
'@types/node': 18.19.3
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
dev: true
@@ -3562,7 +3553,7 @@ packages:
resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==}
dependencies:
'@types/mime': 3.0.1
'@types/node': 18.11.18
'@types/node': 18.19.3
dev: true
/@types/stack-utils@2.0.1:
@@ -4387,7 +4378,7 @@ packages:
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dependencies:
bytes: 3.1.2
content-type: 1.0.4
content-type: 1.0.5
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
@@ -4567,7 +4558,7 @@ packages:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
dependencies:
function-bind: 1.1.2
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
/callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@@ -4822,6 +4813,11 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
dev: false
/convert-source-map@1.9.0:
resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
dev: false
@@ -5335,8 +5331,8 @@ packages:
resolution: {integrity: sha512-/bKPPcgZVUziECqDc+0HkT87+0zhaWSZHNXqF8FLd2lQcptpmUFwoCSWjCdOng9Gdq+afKArPdEg/0ZW461Eng==}
dev: true
/electron@28.0.0:
resolution: {integrity: sha512-eDhnCFBvG0PGFVEpNIEdBvyuGUBsFdlokd+CtuCe2ER3P+17qxaRfWRxMmksCOKgDHb5Wif5UxqOkZSlA4snlw==}
/electron@28.3.3:
resolution: {integrity: sha512-ObKMLSPNhomtCOBAxFS8P2DW/4umkh72ouZUlUKzXGtYuPzgr1SYhskhFWgzAsPtUzhL2CzyV2sfbHcEW4CXqw==}
engines: {node: '>= 12.20.55'}
hasBin: true
requiresBuild: true
@@ -5401,7 +5397,7 @@ packages:
es-to-primitive: 1.2.1
function-bind: 1.1.2
function.prototype.name: 1.1.5
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
get-symbol-description: 1.0.0
globalthis: 1.0.3
gopd: 1.0.1
@@ -5448,7 +5444,7 @@ packages:
resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==}
engines: {node: '>= 0.4'}
dependencies:
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
has: 1.0.3
has-tostringtag: 1.0.0
dev: true
@@ -6217,7 +6213,7 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
define-properties: 1.2.1
es-abstract: 1.21.1
functions-have-names: 1.2.3
dev: true
@@ -6270,6 +6266,7 @@ packages:
function-bind: 1.1.2
has: 1.0.3
has-symbols: 1.0.3
dev: true
/get-intrinsic@1.2.2:
resolution: {integrity: sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==}
@@ -6278,7 +6275,6 @@ packages:
has-proto: 1.0.1
has-symbols: 1.0.3
hasown: 2.0.0
dev: true
/get-nonce@1.0.1:
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
@@ -6305,7 +6301,7 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
dev: true
/glob-parent@5.1.2:
@@ -6521,7 +6517,6 @@ packages:
/has-proto@1.0.1:
resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==}
engines: {node: '>= 0.4'}
dev: true
/has-symbols@1.0.3:
resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==}
@@ -6545,7 +6540,6 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
function-bind: 1.1.2
dev: true
/hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -6723,7 +6717,7 @@ packages:
resolution: {integrity: sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==}
engines: {node: '>= 0.4'}
dependencies:
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
has: 1.0.3
side-channel: 1.0.4
dev: true
@@ -7895,27 +7889,6 @@ packages:
engines: {node: '>= 0.8'}
dev: false
/passport-local@1.0.0:
resolution: {integrity: sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==}
engines: {node: '>= 0.4.0'}
dependencies:
passport-strategy: 1.0.0
dev: false
/passport-strategy@1.0.0:
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
engines: {node: '>= 0.4.0'}
dev: false
/passport@0.6.0:
resolution: {integrity: sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==}
engines: {node: '>= 0.4.0'}
dependencies:
passport-strategy: 1.0.0
pause: 0.0.1
utils-merge: 1.0.1
dev: false
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -7963,10 +7936,6 @@ packages:
resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==}
dev: true
/pause@0.0.1:
resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==}
dev: false
/pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
dev: true
@@ -7975,6 +7944,10 @@ packages:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
dev: true
/picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
dev: true
/picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
@@ -8433,7 +8406,7 @@ packages:
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
define-properties: 1.2.1
functions-have-names: 1.2.3
dev: true
@@ -8576,7 +8549,7 @@ packages:
resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
is-regex: 1.1.4
dev: true
@@ -8737,7 +8710,7 @@ packages:
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
dependencies:
call-bind: 1.0.2
get-intrinsic: 1.1.3
get-intrinsic: 1.2.2
object-inspect: 1.12.3
/siginfo@2.0.0:
@@ -8939,7 +8912,7 @@ packages:
resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
define-properties: 1.2.1
es-abstract: 1.21.1
dev: true
@@ -8947,7 +8920,7 @@ packages:
resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
define-properties: 1.2.1
es-abstract: 1.21.1
dev: true
@@ -9967,8 +9940,8 @@ packages:
readable-stream: 3.6.2
dev: true
/zustand@4.5.0(@types/react@18.0.26)(react@18.2.0):
resolution: {integrity: sha512-zlVFqS5TQ21nwijjhJlx4f9iGrXSL0o/+Dpy4txAP22miJ8Ti6c1Ol1RLNN98BMib83lmDH/2KmLwaNXpjrO1A==}
/zustand@4.5.2(@types/react@18.0.26)(react@18.2.0):
resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'

Some files were not shown because too many files have changed in this diff Show More