mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-02 05:57:59 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ebd31c54 | |||
| 6d1a911ab8 | |||
| ee1753ef29 | |||
| 147e5f5a27 | |||
| a02b3faacc | |||
| a3b21072eb | |||
| 2076080c5f | |||
| 8b3abe9d61 | |||
| c2039866b4 | |||
| 73e4a15718 | |||
| c4359af2a0 | |||
| f75f45b19a | |||
| fd2eee32aa | |||
| a53d354b16 | |||
| ec55236eb2 | |||
| 3895b37572 | |||
| d8626ff324 | |||
| 6c3870c15b | |||
| 4fadb23b07 | |||
| 199ed1617f | |||
| 2d618e0657 | |||
| f928c1dc95 | |||
| 8d526c7d31 | |||
| 686c6108bf | |||
| ea3d33ca93 | |||
| 6bf94ff645 | |||
| 930cd71ffd | |||
| 45cf1cf199 | |||
| 354f464263 | |||
| c46a00febc | |||
| e991e151db | |||
| b5993b91e1 | |||
| 4beac19c4a | |||
| be852f4546 | |||
| 84e224dd71 | |||
| 7cbb2112ed | |||
| ce910c9cc6 | |||
| 9d04954d87 | |||
| 345ea6722e | |||
| f43221cf7a | |||
| a09aa922bb | |||
| 16f31a07b7 | |||
| 77d111650a | |||
| 72891a69f4 | |||
| b8d5a30caf | |||
| 9cbd4c085b | |||
| afe0323384 | |||
| 1907abc512 | |||
| 834aafe06d | |||
| 98418014d3 | |||
| 3d13e784ea | |||
| e397500d07 | |||
| c7c0241859 | |||
| 71a7fe96d1 | |||
| d0f958c13d | |||
| 7ee98fb062 | |||
| c2b66a4215 | |||
| 1d0ee10c00 |
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 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -4,19 +4,22 @@
|
||||
[](https://github.com/sponsors/cpvalente)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
@@ -3,6 +3,6 @@ export function isMacOS() {
|
||||
return userAgent.includes('macintosh') || userAgent.includes('mac os');
|
||||
}
|
||||
|
||||
export const deviceAlt = isMacOS() ? '⌥' : 'Alt';
|
||||
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
|
||||
|
||||
export const deviceMod = isMacOS() ? '⌘' : 'Ctrl';
|
||||
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
|
||||
|
||||
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
|
||||
export const startsWithSlash = /^\//;
|
||||
export const isAlphanumeric = /^[a-z0-9]+$/i;
|
||||
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
|
||||
export const isNotEmpty = /\S/;
|
||||
|
||||
@@ -17,4 +17,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number;
|
||||
}
|
||||
}
|
||||
|
||||
export default {};
|
||||
|
||||
@@ -1,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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -124,7 +124,7 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
|
||||
{
|
||||
label: 'Operator',
|
||||
click: async () => {
|
||||
await shell.openExternal(`${urlBase}/operator`);
|
||||
await shell.openExternal(`${urlBase}/op`);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.0.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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MaybeNumber } from 'ontime-types';
|
||||
|
||||
import { millisToHours, millisToMinutes, millisToSeconds } from './conversionUtils.js';
|
||||
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
|
||||
|
||||
function pad(val: number): string {
|
||||
return String(val).padStart(2, '0');
|
||||
@@ -21,12 +21,13 @@ export function millisToString(millis?: MaybeNumber, options?: FormatOptions): s
|
||||
return options?.fallback ?? '...';
|
||||
}
|
||||
|
||||
const absoluteMillis = Math.abs(millis);
|
||||
const seconds = millisToSeconds(absoluteMillis) % 60;
|
||||
const minutes = millisToMinutes(absoluteMillis) % 60;
|
||||
const hours = millisToHours(absoluteMillis);
|
||||
const isNegative = millis < 0;
|
||||
|
||||
const totalSeconds = Math.abs(millisToSeconds(millis));
|
||||
const seconds = totalSeconds % 60;
|
||||
const minutes = secondsToMinutes(totalSeconds) % 60;
|
||||
const hours = secondsToHours(totalSeconds);
|
||||
|
||||
return `${isNegative ? '-' : ''}${[hours, minutes, seconds].map(pad).join(':')}`;
|
||||
}
|
||||
|
||||
@@ -71,14 +72,14 @@ export function removeSeconds(timer: string): string {
|
||||
|
||||
/**
|
||||
* Formats a given date into a custom string format based on UTC time.
|
||||
*
|
||||
*
|
||||
* @param millis - The number of milliseconds.
|
||||
* @param format - A string specifying the desired output format.
|
||||
* @param format - A string specifying the desired output format.
|
||||
* For example, 'ss' will format the millis as '07' seconds.
|
||||
*
|
||||
*
|
||||
* @returns The formatted date as a string according to the provided `format` string.
|
||||
* If input `millis` is smaller than zero, it returns undefined.
|
||||
*
|
||||
*
|
||||
*/
|
||||
export function formatFromMillis(millis: number, format: string): string | undefined {
|
||||
if (millis < 0) {
|
||||
@@ -94,21 +95,21 @@ export function formatFromMillis(millis: number, format: string): string | undef
|
||||
const secondPadded = date.getUTCSeconds().toString().padStart(2, '0');
|
||||
const second = date.getUTCSeconds().toString();
|
||||
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
|
||||
const hour12 = ((date.getUTCHours() % 12) || 12).toString();
|
||||
const hour12 = (date.getUTCHours() % 12 || 12).toString();
|
||||
const hour12Padded = hour12.padStart(2, '0');
|
||||
const amPm = date.getUTCHours() >= 12 ? 'PM' : 'AM';
|
||||
|
||||
const replacements: Record<string, string> = {
|
||||
'HH': hour24Padded,
|
||||
'H': hour24,
|
||||
'hh': hour12Padded,
|
||||
'h': hour12,
|
||||
'mm': minutePadded,
|
||||
'm': minute,
|
||||
'ss': secondPadded,
|
||||
's': second,
|
||||
'S': milliseconds,
|
||||
'a': amPm
|
||||
HH: hour24Padded,
|
||||
H: hour24,
|
||||
hh: hour12Padded,
|
||||
h: hour12,
|
||||
mm: minutePadded,
|
||||
m: minute,
|
||||
ss: secondPadded,
|
||||
s: second,
|
||||
S: milliseconds,
|
||||
a: amPm,
|
||||
};
|
||||
|
||||
return applyReplacements(format, replacements);
|
||||
@@ -123,7 +124,7 @@ export function formatFromMillis(millis: number, format: string): string | undef
|
||||
*/
|
||||
function applyReplacements(template: string, replacements: Record<string, string>): string {
|
||||
return Object.keys(replacements).reduce((result, token) => {
|
||||
const regex = new RegExp(`\\b${token}\\b`, 'g');
|
||||
return result.replace(regex, replacements[token]);
|
||||
const regex = new RegExp(`\\b${token}\\b`, 'g');
|
||||
return result.replace(regex, replacements[token]);
|
||||
}, template);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { splitWhitespace } from './splitWhitespace';
|
||||
|
||||
describe('test splitWhitespace() function', () => {
|
||||
it('empty string', () => {
|
||||
const test = '';
|
||||
expect(splitWhitespace(test)).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('just space', () => {
|
||||
const test = ' ';
|
||||
expect(splitWhitespace(test)).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('1 item', () => {
|
||||
const test = 'test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test']);
|
||||
});
|
||||
|
||||
it('2 items', () => {
|
||||
const test = 'test test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test']);
|
||||
});
|
||||
|
||||
it('2 items and quoted string', () => {
|
||||
const test = 'test test "more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
|
||||
});
|
||||
|
||||
it('2 sapces', () => {
|
||||
const test = 'test test "more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test"']);
|
||||
});
|
||||
|
||||
it('quotes without spaces', () => {
|
||||
const test = 'test test "moreTest"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"moreTest"']);
|
||||
});
|
||||
|
||||
it('escaped quotes', () => {
|
||||
const test = 'test test "more \\" test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more " test"']);
|
||||
});
|
||||
|
||||
it('missing end quotes', () => {
|
||||
const test = 'test test "more test';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', '"more test']);
|
||||
});
|
||||
|
||||
it('missing start quotes', () => {
|
||||
const test = 'test test more test"';
|
||||
expect(splitWhitespace(test)).toStrictEqual(['test', 'test', 'more', 'test"']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
const splitRegex = /\\?.|^$/g;
|
||||
|
||||
/**
|
||||
* adapted from {@link https://stackoverflow.com/questions/4031900/split-a-string-by-whitespace-keeping-quoted-segments-allowing-escaped-quotes this}
|
||||
* @param str string to split
|
||||
* @returns
|
||||
*/
|
||||
export const splitWhitespace = (str: string, keepQuotes = true): null | string[] => {
|
||||
const match = str.match(splitRegex);
|
||||
if (!match || match[0] == '') {
|
||||
return null;
|
||||
}
|
||||
const array = match
|
||||
.reduce(
|
||||
(accumulator, current) => {
|
||||
if (current === '"') {
|
||||
accumulator.inQuotes ^= 1;
|
||||
if (keepQuotes) {
|
||||
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
|
||||
}
|
||||
} else if (!accumulator.inQuotes && current === ' ') {
|
||||
accumulator.array.push('');
|
||||
} else {
|
||||
accumulator.array[accumulator.array.length - 1] += current.replace(/\\(.)/, '$1');
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{ array: [''], inQuotes: 0 },
|
||||
)
|
||||
.array.filter((value) => value != '');
|
||||
|
||||
if (!array.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array;
|
||||
};
|
||||
Generated
+35
-62
@@ -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
Reference in New Issue
Block a user