mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-03 06:28:01 +00:00
Compare commits
77 Commits
v3.0.0
...
v3.1.0-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 248c952ec7 | |||
| 3cda6b222e | |||
| d161411416 | |||
| 97b8e6a290 | |||
| 036d4051fc | |||
| 7cefe07594 | |||
| d05cc88748 | |||
| 119e6caca3 | |||
| bb199128aa | |||
| dade6f6692 | |||
| 6bedd7ab1d | |||
| eb2c4d0780 | |||
| 4069038ed5 | |||
| ac430602b4 | |||
| 082449bede | |||
| cfbd540a14 | |||
| a431fab3c4 | |||
| 770d975610 | |||
| 6f95aac798 | |||
| dae6f84af0 | |||
| ad46fac2d7 | |||
| 37786c3528 | |||
| c6d36d2497 | |||
| 2394eced93 | |||
| 13aeeb278d | |||
| af5d6e1aee | |||
| bc036bdae7 | |||
| 266a84e5d3 | |||
| 92c3148e49 | |||
| efd05bcc34 | |||
| 3c7e061e51 | |||
| 07074260a4 | |||
| c57b0423af | |||
| 52145d5e1e | |||
| b2668e61a5 | |||
| 7da3480dfe | |||
| e8cfdbe3b9 | |||
| fd052355c3 | |||
| 00f04fb78c | |||
| 6f80de58ca | |||
| 5977266332 |
@@ -11,4 +11,13 @@
|
||||
|
||||
# Ignore git and cache folders
|
||||
.git
|
||||
.gitignore
|
||||
.cache
|
||||
|
||||
# Ignore build folders
|
||||
node_modules
|
||||
dist
|
||||
|
||||
# Ignore default volumes created by running docker compose up
|
||||
ontime-db
|
||||
ontime-styles
|
||||
|
||||
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:
|
||||
|
||||
@@ -88,8 +88,6 @@ While it should allow for a generic setup, it might need to be modified to fit y
|
||||
|
||||
From the project root, run the following commands
|
||||
|
||||
- __Install the project dependencies__ by running `pnpm i`
|
||||
- __Build packages__ by running `pnpm build:localdocker`
|
||||
- __Build docker image from__ by running `docker build -t getontime/ontime`
|
||||
- __Run docker image from compose__ by running `docker-compose up -d`
|
||||
|
||||
|
||||
+13
-4
@@ -1,3 +1,12 @@
|
||||
FROM node:18.18-alpine AS builder
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
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
|
||||
|
||||
# Set environment variables
|
||||
@@ -9,12 +18,12 @@ ENV ONTIME_DATA=/external/
|
||||
WORKDIR /app/
|
||||
|
||||
# Prepare UI
|
||||
COPY /apps/client/build ./client/
|
||||
COPY --from=builder /app/apps/client/build ./client/
|
||||
|
||||
# Prepare Backend
|
||||
COPY /apps/server/dist/ ./server/
|
||||
COPY /demo-db/ ./preloaded-db/
|
||||
COPY /apps/server/src/external/ ./external/
|
||||
COPY --from=builder /app/apps/server/dist/ ./server/
|
||||
COPY ./demo-db/ ./preloaded-db/
|
||||
COPY --from=builder /app/apps/server/src/external/ ./external/
|
||||
|
||||
# Export default ports
|
||||
EXPOSE 4001/tcp 8888/udp 9999/udp
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,57 +132,42 @@ Found a bug? [Open an issue](https://github.com/cpvalente/ontime/issues/new).
|
||||
|
||||
When installing the app you would see warning screens from the Operating System like:
|
||||
|
||||
in Windows
|
||||
|
||||
`Microsoft Defender SmartScreen prevented an unrecognised app from starting. Running this app might put your PC at risk.`
|
||||
|
||||
or
|
||||
|
||||
`Ontime can't be opened because it is from an unidentified developer`
|
||||
|
||||
or in Linux
|
||||
|
||||
`Could Not Display "ontime-linux.AppImage`
|
||||
|
||||
We currently only sign MacOS releases. \
|
||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense which we are not able to cover.
|
||||
|
||||
You can circumvent this by allowing the execution of the app manually.
|
||||
|
||||
- In Windows: click more and select "Run Anyway"
|
||||
- in macOS: the solution in macOS is different across versions, please refer to
|
||||
the [apple documentation](https://support.apple.com/en-gb/guide/mac-help/mh40616/mac)
|
||||
- In Linux: right-click the AppImage file -> Properties -> Permissions -> select Allow Executing
|
||||
File as a Program
|
||||
- In Windows: click `more` -> `Run Anyway`
|
||||
- In Linux: right-click the AppImage file: `Properties` -> `Permissions` -> `Allow Executing File as a Program`
|
||||
|
||||
Long story short: Ontime app is unsigned. \
|
||||
Purchasing the certificates for both Mac and Windows would mean a recurrent expense and is not a priority. \
|
||||
This is unlikely to change in future. \
|
||||
If you have tips on how to improve this or would like to sponsor the code signing,
|
||||
please [open an issue](https://github.com/cpvalente/ontime/issues/new)
|
||||
|
||||
#### Safari
|
||||
|
||||
There are known issues with Safari versions lower than 13:
|
||||
|
||||
- Spacing and text styles might have small inconsistencies
|
||||
- Table view does not work
|
||||
|
||||
There is no plan for any further work on this.
|
||||
|
||||
## Contributing
|
||||
|
||||
Looking to contribute? All types of help are appreciated, from coding to testing and feature specification.
|
||||
|
||||
If you are a developer and would like to contribute with some code, please open an issue to discuss before opening a
|
||||
Pull Request.
|
||||
If you are a developer and would like to contribute with code, please open an issue to discuss before opening a Pull Request.
|
||||
|
||||
Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md)
|
||||
|
||||
## Help
|
||||
## Links
|
||||
|
||||
Help is underway! ... and can be found [here](https://docs.getontime.no)
|
||||
See the [Ontime website](https://getontime.no) here and the link to the [documentation](https://docs.getontime.no)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the GNU GPL v3
|
||||
|
||||
## SPONSOR
|
||||
## Sponsor
|
||||
|
||||
You can help the development of this project or say thank you with a one time donation. \
|
||||
See [the terms of donations](https://github.com/cpvalente/ontime/blob/master/.github/FUNDING.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"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",
|
||||
@@ -61,7 +61,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^2.14.0",
|
||||
"@sentry/vite-plugin": "^2.16.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.8.4",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
@@ -87,10 +87,10 @@
|
||||
"prettier": "^3.0.3",
|
||||
"sass": "^1.57.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^5.1.0",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-compression2": "^0.12.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^4.3.1",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -11,15 +11,15 @@ const dbPath = `${apiEntryUrl}/db`;
|
||||
/**
|
||||
* HTTP request to the current DB
|
||||
*/
|
||||
async function getDb(): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.get(`${dbPath}/download`);
|
||||
async function getDb(fileName?: string): Promise<AxiosResponse<DatabaseModel>> {
|
||||
return axios.post(`${dbPath}/download/`, { fileName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Request download of the current project file
|
||||
* @param fileName
|
||||
*/
|
||||
export async function downloadProject(fileName: string = 'ontime-project') {
|
||||
export async function downloadProject(fileName: string) {
|
||||
try {
|
||||
const { data, name } = await fileDownload(fileName);
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function getInfo(): Promise<GetInfo> {
|
||||
* @returns
|
||||
*/
|
||||
async function fileDownload(fileName: string): Promise<{ data: DatabaseModel; name: string }> {
|
||||
const response = await getDb();
|
||||
const response = await getDb(fileName);
|
||||
|
||||
const headerLine = response.headers['Content-Disposition'];
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -103,7 +103,7 @@ 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,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isOntimeEvent, MaybeString, OntimeEvent, OntimeRundownEntry, RundownCached } from 'ontime-types';
|
||||
import { getLinkedTimes, getPreviousEventNormal, reorderArray, swapEventData } from 'ontime-utils';
|
||||
import {
|
||||
dayInMs,
|
||||
getLinkedTimes,
|
||||
getPreviousEventNormal,
|
||||
MILLIS_PER_SECOND,
|
||||
parseUserTime,
|
||||
reorderArray,
|
||||
swapEventData,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { RUNDOWN } from '../api/constants';
|
||||
import {
|
||||
@@ -18,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
|
||||
@@ -87,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,14 +217,17 @@ 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
|
||||
const cappedMillis = Math.min(newValMillis, dayInMs - MILLIS_PER_SECOND);
|
||||
|
||||
const newEvent = {
|
||||
id: eventId,
|
||||
[field]: newValMillis,
|
||||
[field]: cappedMillis,
|
||||
};
|
||||
try {
|
||||
await _updateEventMutation.mutateAsync(newEvent);
|
||||
@@ -327,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 });
|
||||
|
||||
@@ -336,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,
|
||||
@@ -367,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,263 +1,4 @@
|
||||
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: 60 * 1000 },
|
||||
{ value: '-1', expect: 60 * 1000 },
|
||||
{ value: '0h0m0s', expect: 0 },
|
||||
{ value: '0h0m1s', expect: 1000 },
|
||||
{ value: '0h1m0s', expect: 1000 * 60 },
|
||||
{ value: '1h0m0s', expect: 1000 * 60 * 60 },
|
||||
{ value: '23h0m0s', expect: 1000 * 60 * 60 * 23 },
|
||||
{ value: '12h12m12s', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
{ value: '12H12M12S', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
{ value: '2m', expect: 2 * 60 * 1000 },
|
||||
{ value: '1h5s', expect: 1000 * 60 * 60 + 1000 * 5 },
|
||||
{ value: '1h2m', expect: 1000 * 60 * 60 + 1000 * 60 * 2 },
|
||||
];
|
||||
|
||||
for (const s of testData) {
|
||||
it(`handles ${s.value} to left`, () => {
|
||||
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: 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
||||
{ value: '12.1.1', expect: 12 * 60 * 60 * 1000 + 60 * 1000 + 1000 },
|
||||
{ value: '12.55.1', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 1000 },
|
||||
{ value: '12.55.40', expect: 12 * 60 * 60 * 1000 + 55 * 60 * 1000 + 40 * 1000 },
|
||||
];
|
||||
|
||||
for (const s of ts) {
|
||||
it(`handles ${s.value} to the left`, () => {
|
||||
expect(forgivingStringToMillis(s.value)).toBe(s.expect);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('parses time strings', () => {
|
||||
const ts = [
|
||||
{ value: '1h2m3s', expect: 60 * 60 * 1000 + 2 * 60 * 1000 + 3 * 1000 },
|
||||
{ value: '1h3s', expect: 60 * 60 * 1000 + 3 * 1000 },
|
||||
{ value: '1h2m', expect: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
||||
{ value: '10h', expect: 10 * 60 * 60 * 1000 },
|
||||
{ value: '10m', expect: 10 * 60 * 1000 },
|
||||
{ value: '10s', expect: 10 * 1000 },
|
||||
{ value: '120h', expect: 120 * 60 * 60 * 1000 },
|
||||
{ value: '120m', expect: 120 * 60 * 1000 },
|
||||
{ value: '120s', expect: 120 * 1000 },
|
||||
];
|
||||
|
||||
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 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10:11p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 + 11 * 1000 },
|
||||
{ value: '9:10am', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10a', expect: 9 * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10pm', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9:10p', expect: (12 + 9) * 60 * 60 * 1000 + 10 * 60 * 1000 },
|
||||
{ value: '9am', expect: 9 * 60 * 60 * 1000 },
|
||||
{ value: '9a', expect: 9 * 60 * 60 * 1000 },
|
||||
{ value: '9pm', expect: (12 + 9) * 60 * 60 * 1000 },
|
||||
{ value: '9p', expect: (12 + 9) * 60 * 60 * 1000 },
|
||||
{ value: '12am', expect: 0 },
|
||||
{ value: '12pm', expect: 12 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
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: 1000 * 60 }, // 00:01:00
|
||||
{ value: '12', expect: 1000 * 60 * 12 }, // 00:12:00
|
||||
{ value: '123', expect: 1000 * 60 * 23 + 1000 * 60 * 60 }, // 01:23:00
|
||||
{ value: '1234', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 }, // 12:34:00
|
||||
{ value: '12345', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 5 * 1000 }, // 12:34:05
|
||||
{ value: '123456', expect: 1000 * 60 * 34 + 1000 * 60 * 60 * 12 + 56 * 1000 }, // 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 },
|
||||
{ value: '000001', expect: 1000 },
|
||||
{ value: '000100', expect: 1000 * 60 },
|
||||
{ value: '010000', expect: 1000 * 60 * 60 },
|
||||
{ value: '230000', expect: 1000 * 60 * 60 * 23 },
|
||||
{ value: '121212', expect: 12 * 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 },
|
||||
];
|
||||
|
||||
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: 1000 }, // 00:00:01
|
||||
{ value: '00010', expect: 1000 * 60 }, // 00:01:00
|
||||
{ value: '00100', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '01000', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '10000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
||||
{ value: '23000', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
||||
{ value: '12121', expect: 1000 + 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 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: 1000 * 60 }, // 00:01:00
|
||||
{ value: '0010', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '0100', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '1000', expect: 1000 * 60 * 60 * 10 }, // 10:00:00
|
||||
{ value: '2300', expect: 1000 * 60 * 60 * 23 }, // 23:00:00
|
||||
{ value: '1212', expect: 12 * 60 * 1000 + 12 * 1000 * 60 * 60 }, // 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: 1000 * 60 }, // 00:01:00
|
||||
{ value: '010', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '100', expect: 1000 * 60 * 60 }, // 01:00:00
|
||||
{ value: '230', expect: 2 * 1000 * 60 * 60 + 30 * 1000 * 60 }, // 02:30:00
|
||||
{ value: '121', expect: 21 * 60 * 1000 + 1000 * 60 * 60 }, // 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: 1000 * 60 }, // 00:01:00
|
||||
{ value: '10', expect: 1000 * 60 * 10 }, // 00:10:00
|
||||
{ value: '23', expect: 1000 * 60 * 23 }, // 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: '2.0.0', expect: 1000 * 60 * 120 },
|
||||
{ value: '99', expect: 1000 * 60 * 99 },
|
||||
{ value: '1.39.0', expect: 1000 * 60 * 99 },
|
||||
// seconds overflow
|
||||
{ value: '0.0.120', expect: 120 * 1000 },
|
||||
{ value: '0.2.0', expect: 120 * 1000 },
|
||||
{ value: '0.0.99', expect: 99 * 1000 },
|
||||
{ value: '0.1.39', expect: 99 * 1000 },
|
||||
// hours overflow
|
||||
{ value: '25.0.0', expect: 1000 * 60 * 60 * 25 },
|
||||
// hours overflow
|
||||
{ value: '50.0.0', expect: 1000 * 60 * 60 * 50 },
|
||||
];
|
||||
|
||||
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: 3723000 },
|
||||
{ value: '2,10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
{ value: '2.10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
{ value: '2 10', expect: 2 * 60 * 60 * 1000 + 60 * 10 * 1000 },
|
||||
];
|
||||
|
||||
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: 60 * 60 * 1000 + 2 * 60 * 1000 },
|
||||
{ value: '1.70', expect: 60 * 60 * 1000 + 70 * 60 * 1000 },
|
||||
];
|
||||
|
||||
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 * 60 * 1000 },
|
||||
{ value: '0.99', expect: 99 * 60 * 1000 },
|
||||
];
|
||||
|
||||
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,163 +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;
|
||||
} else if (length >= 6) {
|
||||
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) {
|
||||
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/;
|
||||
|
||||
+2
-2
@@ -93,10 +93,10 @@ export default function UrlPresetsForm() {
|
||||
<Panel.SubHeader>
|
||||
URL presets
|
||||
<div className={style.actionButtons}>
|
||||
<Button variant='ontime-ghosted' size='md' onClick={onReset} isDisabled={!canSubmit}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='md' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,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';
|
||||
@@ -221,7 +221,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 +277,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>
|
||||
|
||||
@@ -169,7 +169,7 @@ function ActionMenu({
|
||||
<MenuItem onClick={handleRename}>Rename</MenuItem>
|
||||
<MenuItem onClick={handleDuplicate}>Duplicate</MenuItem>
|
||||
<MenuItem onClick={handleDownload}>Download</MenuItem>
|
||||
{current && <MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>}
|
||||
<MenuItem onClick={handleExportCSV}>Export CSV Rundown</MenuItem>
|
||||
<MenuItem isDisabled={current} onClick={handleDelete}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
|
||||
@@ -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_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,11 +18,12 @@ 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);
|
||||
setTime(newTime);
|
||||
const newTime = parseUserTime(value);
|
||||
// cap add time to 1 hour
|
||||
setTime(Math.min(newTime, MILLIS_PER_HOUR));
|
||||
};
|
||||
|
||||
const handleAddTime = (direction: 'add' | 'remove') => {
|
||||
|
||||
@@ -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';
|
||||
@@ -64,7 +64,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,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';
|
||||
@@ -27,14 +27,23 @@ interface CuesheetProps {
|
||||
export default function Cuesheet({ data, columns, handleUpdate, selectedId }: CuesheetProps) {
|
||||
const { followSelected, showSettings, showDelayBlock, showPrevious } = 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} />
|
||||
<tbody>
|
||||
{rowModel.rows.map((row) => {
|
||||
const key = row.original.id;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.tableWrapper {
|
||||
background-color: $ui-black;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 1rem 0.5rem;
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
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 +21,11 @@ import style from '../Cuesheet.module.scss';
|
||||
|
||||
interface CuesheetHeaderProps {
|
||||
headerGroups: HeaderGroup<OntimeRundownEntry>[];
|
||||
saveColumnOrder: (fromId: string, toId: string) => void;
|
||||
}
|
||||
|
||||
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 } = props;
|
||||
|
||||
const handleOnDragEnd = (event: DragEndEvent) => {
|
||||
const { delta, active, over } = event;
|
||||
@@ -45,21 +35,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 +51,6 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
tolerance: 50,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -86,7 +59,7 @@ 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}>
|
||||
@@ -119,5 +92,3 @@ function CuesheetHeader(props: CuesheetHeaderProps) {
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CuesheetHeader);
|
||||
|
||||
@@ -9,11 +9,11 @@ $panel-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mainContainer {
|
||||
background: $ui-black;
|
||||
background-color: $ui-black;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: $ui-white;
|
||||
padding: 1rem 0.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
background-color: $ui-black;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function TitlesOverview() {
|
||||
}
|
||||
|
||||
function TimerOverview() {
|
||||
const {current} = useTimer();
|
||||
const { current } = useTimer();
|
||||
|
||||
const display = millisToString(current);
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { dayInMs } from 'ontime-utils';
|
||||
|
||||
import { calculateEndAndDaySpan } from '../overviewUtils';
|
||||
|
||||
describe('calculateEndAndDaySpan', () => {
|
||||
it('should return [null, 0] when end is null', () => {
|
||||
const result = calculateEndAndDaySpan(null);
|
||||
expect(result).toEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('should return [end, 0] when end is less than or equal to dayInMs', () => {
|
||||
const end = dayInMs / 2;
|
||||
const result = calculateEndAndDaySpan(end);
|
||||
expect(result).toEqual([end, 0]);
|
||||
});
|
||||
|
||||
it('should return [end % dayInMs, Math.floor(end / dayInMs)] when end is greater than dayInMs', () => {
|
||||
const end = dayInMs * 1.5;
|
||||
const result = calculateEndAndDaySpan(end);
|
||||
expect(result).toEqual([end % dayInMs, Math.floor(end / dayInMs)]);
|
||||
});
|
||||
});
|
||||
@@ -13,20 +13,14 @@ export function formatedTime(time: MaybeNumber) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a day span from a number range
|
||||
* @param end
|
||||
* @returns
|
||||
* Calculates how long a time is and how many days it spans
|
||||
*/
|
||||
export function calculateEndAndDaySpan(end: MaybeNumber): [MaybeNumber, number] {
|
||||
let maybeEnd = end;
|
||||
let maybeDaySpan = 0;
|
||||
if (end !== null) {
|
||||
if (end > dayInMs) {
|
||||
maybeEnd = end % dayInMs;
|
||||
maybeDaySpan = Math.floor(end / dayInMs);
|
||||
}
|
||||
if (end !== null && end > dayInMs) {
|
||||
return [end % dayInMs, Math.floor(end / dayInMs)];
|
||||
}
|
||||
return [maybeEnd, maybeDaySpan];
|
||||
|
||||
return [end, 0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function Rundown({ data }: RundownProps) {
|
||||
(cursor: string | null) => {
|
||||
if (!cursor) return;
|
||||
const previous = getPreviousNormal(rundown, order, cursor).entry?.id ?? null;
|
||||
deleteEvent(cursor);
|
||||
deleteEvent([cursor]);
|
||||
setCursor(previous);
|
||||
},
|
||||
[deleteEvent, order, rundown, setCursor],
|
||||
|
||||
@@ -58,18 +58,20 @@ export default function RundownEntry(props: RundownEntryProps) {
|
||||
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);
|
||||
// clear cursor if we are deleting the event that is currently selected
|
||||
if (cursor === data.id) {
|
||||
setCursor(null);
|
||||
}
|
||||
}, [selectedEvents, data.id, cursor, clearSelectedEvents, setCursor]);
|
||||
}, [unselect, data.id, cursor, setCursor]);
|
||||
|
||||
const clearMultiSelection = useCallback(() => {
|
||||
clearSelectedEvents();
|
||||
setCursor(null);
|
||||
}, [clearSelectedEvents, setCursor]);
|
||||
|
||||
// Create / delete new events
|
||||
type FieldValue = {
|
||||
@@ -111,10 +113,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 +138,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
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
flex: 1 2 auto; /* flex-grow: 1, flex-shrink: 2, flex-basis: auto */
|
||||
min-width: 38rem;
|
||||
max-width: 45rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
.side {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -113,6 +113,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
value: false,
|
||||
}),
|
||||
},
|
||||
{ withDivider: true, label: 'Delete', icon: IoTrash, onClick: () => actionHandler('delete') },
|
||||
]
|
||||
: [
|
||||
{
|
||||
@@ -242,13 +243,7 @@ export default function EventBlock(props: EventBlockProps) {
|
||||
onContextMenu={onContextMenu}
|
||||
id='event-block'
|
||||
>
|
||||
<RundownIndicators
|
||||
timeStart={timeStart}
|
||||
timeEnd={timeEnd}
|
||||
previousStart={previousStart}
|
||||
previousEnd={previousEnd}
|
||||
delay={delay}
|
||||
/>
|
||||
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
|
||||
|
||||
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
|
||||
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
import { dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
|
||||
import { checkIsNextDay, dayInMs, millisToString, removeLeadingZero, removeTrailingZero } from 'ontime-utils';
|
||||
|
||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
if (!delay) return;
|
||||
@@ -14,20 +14,23 @@ export function formatOverlap(
|
||||
previousStart: MaybeNumber,
|
||||
previousEnd: MaybeNumber,
|
||||
timeStart: number,
|
||||
timeEnd: number,
|
||||
): string | undefined {
|
||||
if (previousEnd === null) return;
|
||||
const noPreviousElement = previousEnd === null || previousStart === null;
|
||||
if (noPreviousElement) return;
|
||||
|
||||
const overlap = previousEnd - timeStart;
|
||||
if (overlap === 0) return;
|
||||
|
||||
if (previousStart && timeStart < previousEnd) {
|
||||
const overlap = timeEnd - previousStart;
|
||||
if (overlap > 0) {
|
||||
const overlapString = removeLeadingZero(millisToString(Math.abs(overlap)));
|
||||
return `Overlap ${overlapString}`;
|
||||
}
|
||||
const gap = timeStart + dayInMs - previousEnd;
|
||||
const previousCrossMidnight = previousStart > previousEnd;
|
||||
const isNextDay = previousCrossMidnight
|
||||
? checkIsNextDay(previousEnd, timeStart) || previousEnd == 0 // exception for when previousEnd is precisely midnight
|
||||
: checkIsNextDay(previousStart, timeStart);
|
||||
|
||||
const correctedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
|
||||
|
||||
if (isNextDay) {
|
||||
const gap = dayInMs - correctedPreviousEnd + timeStart;
|
||||
if (gap === 0) return;
|
||||
const gapString = removeLeadingZero(millisToString(Math.abs(gap)));
|
||||
return `Gap ${gapString} (next day)`;
|
||||
}
|
||||
|
||||
@@ -6,16 +6,15 @@ import style from './RundownIndicators.module.scss';
|
||||
|
||||
interface RundownIndicatorProps {
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
previousStart: MaybeNumber;
|
||||
previousEnd: MaybeNumber;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
export default function RundownIndicators(props: RundownIndicatorProps) {
|
||||
const { timeStart, timeEnd, previousStart, previousEnd, delay } = props;
|
||||
const { timeStart, previousStart, previousEnd, delay } = props;
|
||||
|
||||
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
const hasOverlap = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
const hasDelay = formatDelay(timeStart, delay);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { MILLIS_PER_HOUR } from 'ontime-utils';
|
||||
|
||||
import { formatDelay, formatOverlap } from '../EventBlock.utils';
|
||||
|
||||
describe('formatDelay()', () => {
|
||||
@@ -14,35 +16,47 @@ describe('formatOverlap()', () => {
|
||||
const previousStart = 0;
|
||||
const previousEnd = 60000; // 1 min
|
||||
const timeStart = 30000; // 30 sec
|
||||
const timeEnd = 90000; // 1:30 min
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toEqual('Overlap 0:30');
|
||||
});
|
||||
|
||||
it('bug #949 recognises an overlap between two times', () => {
|
||||
const previousStart = 46800000; // 13:00:00
|
||||
const previousEnd = 48600000; // 13:30:00
|
||||
const timeStart = 48300000; // 13:25:00
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toEqual('Overlap 5:00');
|
||||
});
|
||||
|
||||
it('handles events the day after, without overlap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(11);
|
||||
const previousEnd = new Date(0).setUTCHours(12);
|
||||
const timeStart = new Date(0).setUTCHours(6);
|
||||
const timeEnd = new Date(0).setUTCHours(10);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
const previousStart = 11 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 12 * MILLIS_PER_HOUR;
|
||||
const timeStart = 6 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 18:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with overlap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(9);
|
||||
const previousEnd = new Date(0).setUTCHours(10);
|
||||
const timeStart = new Date(0).setUTCHours(6);
|
||||
const timeEnd = new Date(0).setUTCHours(11);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
expect(result).toBe('Overlap 02:00:00');
|
||||
});
|
||||
|
||||
it('handles events the day after, with gap', () => {
|
||||
const previousStart = new Date(0).setUTCHours(17);
|
||||
const previousEnd = new Date(0).setUTCHours(23);
|
||||
const timeStart = new Date(0).setUTCHours(9);
|
||||
const timeEnd = new Date(0).setUTCHours(11);
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart, timeEnd);
|
||||
const previousStart = 17 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 23 * MILLIS_PER_HOUR;
|
||||
const timeStart = 9 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 10:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending at midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
|
||||
const previousEnd = 0; // 00:00:00
|
||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 01:00:00 (next day)');
|
||||
});
|
||||
|
||||
it('handles events the day after, with previous ending over midnight', () => {
|
||||
const previousStart = 23 * MILLIS_PER_HOUR;
|
||||
const previousEnd = 1 * MILLIS_PER_HOUR;
|
||||
const timeStart = 2 * MILLIS_PER_HOUR;
|
||||
const result = formatOverlap(previousStart, previousEnd, timeStart);
|
||||
expect(result).toBe('Gap 01:00:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,21 +14,36 @@
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
font-size: calc(1rem - 2px);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
margin-top: 0.5rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
border-collapse: separate;
|
||||
border-spacing: 1rem 0;
|
||||
border-spacing: 4rem 0;
|
||||
|
||||
tr {
|
||||
td:nth-child(odd) {
|
||||
text-align: left;
|
||||
}
|
||||
td:nth-child(even) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 1rem;
|
||||
text-align: left;
|
||||
margin-left: 4rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
@@ -10,89 +10,127 @@ export default memo(EventEditorEmpty);
|
||||
function EventEditorEmpty() {
|
||||
return (
|
||||
<div className={style.eventEditor} data-testid='editor-container'>
|
||||
<div className={style.prompt}>Select an event to edit</div>
|
||||
<div className={style.shortcutSection}>
|
||||
<div className={style.prompt}>Shortcut navigation</div>
|
||||
|
||||
<div className={style.prompt}>Rundown shortcuts:</div>
|
||||
<table className={style.shortcuts}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> / <Kbd>↓</Kbd>
|
||||
</td>
|
||||
<td>Select entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>{deviceMod}</Kbd> + <Kbd>↑</Kbd> / <Kbd>↑</Kbd>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
<td>Reorder selected entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>E</Kbd>
|
||||
</td>
|
||||
<td>Add event below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>E</Kbd>
|
||||
</td>
|
||||
<td>Add event above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>B</Kbd>
|
||||
</td>
|
||||
<td>Add block below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>B</Kbd>
|
||||
</td>
|
||||
<td>Add block above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>D</Kbd>
|
||||
</td>
|
||||
<td>Add delay below</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd> + <Kbd>↑</Kbd> + <Kbd>D</Kbd>
|
||||
</td>
|
||||
<td>Add delay above</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Deselect entry</td>
|
||||
<td>
|
||||
<Kbd>Esc</Kbd>
|
||||
</td>
|
||||
<td>Deselect entry</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Reorder selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>↑</Kbd>
|
||||
<AuxKey>/</AuxKey>
|
||||
<Kbd>↓</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>⌫</Kbd>
|
||||
</td>
|
||||
<td>Delete selected entry</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>C</Kbd>
|
||||
</td>
|
||||
<td>Copy selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>C</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>↑</Kbd> + <Kbd>V</Kbd>
|
||||
</td>
|
||||
<td>Paste above</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd> + <Kbd>V</Kbd>
|
||||
</td>
|
||||
<td>Paste below</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>V</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Delete selected entry</td>
|
||||
<td>
|
||||
<Kbd>{deviceMod}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Backspace</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={style.spacer} />
|
||||
<tr>
|
||||
<td>Add event below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add event above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>E</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add block below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>B</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add block above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>B</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay below</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Add delay above</td>
|
||||
<td>
|
||||
<Kbd>{deviceAlt}</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>Shift</Kbd>
|
||||
<AuxKey>+</AuxKey>
|
||||
<Kbd>D</Kbd>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -100,3 +138,7 @@ function EventEditorEmpty() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuxKey({ children }: { children: React.ReactNode }) {
|
||||
return <span className={style.divider}>{children}</span>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ interface EventSelectionStore {
|
||||
anchoredIndex: number | null;
|
||||
setSelectedEvents: (selectionArgs: { id: string; index: number; selectMode: SelectionMode }) => void;
|
||||
clearSelectedEvents: () => void;
|
||||
clearMultiSelect: () => void;
|
||||
unselect: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
@@ -81,7 +83,17 @@ export const useEventSelection = create<EventSelectionStore>()((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
clearSelectedEvents: () => set({ selectedEvents: new Set() }),
|
||||
clearSelectedEvents: () => set({ selectedEvents: new Set(), anchoredIndex: 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 {
|
||||
|
||||
+21
-10
@@ -6,8 +6,11 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
src:
|
||||
url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff2) format('woff2'),
|
||||
url(@fontsource/open-sans/files/open-sans-latin-300-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* open-sans-latin-400-normal */
|
||||
@@ -16,8 +19,11 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
src:
|
||||
url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff2) format('woff2'),
|
||||
url(@fontsource/open-sans/files/open-sans-latin-400-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* open-sans-latin-600-normal */
|
||||
@@ -26,11 +32,13 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 600;
|
||||
src: url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff2) format('woff2'), url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||
src:
|
||||
url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff2) format('woff2'),
|
||||
url(@fontsource/open-sans/files/open-sans-latin-600-normal.woff) format('woff');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
$track-color: $white-1;
|
||||
$thumb-color: $gray-1100;
|
||||
$thumb-color-hover: $gray-900;
|
||||
@@ -49,11 +57,15 @@ $thumb-color-hover: $gray-900;
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
font-size: 15px;
|
||||
font-family: $ontime-font-family;
|
||||
background-color: var(--background-color-override, $ui-black);
|
||||
}
|
||||
|
||||
body,
|
||||
html,
|
||||
.App {
|
||||
font-size: 15px;
|
||||
font-family: $ontime-font-family;
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
@@ -61,7 +73,6 @@ html,
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-app-region: drag;
|
||||
background-color: $bg-container-l1;
|
||||
}
|
||||
|
||||
/* smaller root size for MacOS laptops */
|
||||
|
||||
@@ -83,9 +83,13 @@ const theme = extendTheme({
|
||||
Kbd: {
|
||||
baseStyle: {
|
||||
borderRadius: '2px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.20)',
|
||||
background: 'rgba(255, 255, 255, 0.10)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
background: '#262626', // $gray-1200
|
||||
padding: '0.125rem 0.5rem',
|
||||
color: '#f6f6f6', // $ui-white
|
||||
fontWeight: 400,
|
||||
boxShadow: '0px 0px 3px 0px rgba(0,0,0,0.4)',
|
||||
fontSize: 'calc(1rem - 2px)',
|
||||
},
|
||||
},
|
||||
Menu: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
@@ -10,9 +10,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.0.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"express-static-gzip": "^2.1.7",
|
||||
"express-validator": "^6.14.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"fast-equals": "^5.0.1",
|
||||
"google-auth-library": "^9.4.2",
|
||||
"got": "^14.0.0",
|
||||
@@ -47,7 +46,7 @@
|
||||
"shx": "^0.3.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"addversion": "node -p \"'export const ONTIME_VERSION = ' + JSON.stringify(require('../../package.json').version) + ';'\" > src/ONTIME_VERSION.js",
|
||||
|
||||
@@ -6,17 +6,19 @@ import {
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { failEmptyObjects } from '../../utils/routerUtils.js';
|
||||
import { resolveDbPath, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
import { resolveDbDirectory, resolveProjectsDirectory } from '../../setup/index.js';
|
||||
|
||||
import * as projectService from '../../services/project-service/ProjectService.js';
|
||||
import { ensureJsonExtension } from '../../utils/fileManagement.js';
|
||||
import { generateUniqueFileName } from '../../utils/generateUniqueFilename.js';
|
||||
import { appStateService } from '../../services/app-state-service/AppStateService.js';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
export async function patchPartialProjectFile(req: Request, res: Response<DatabaseModel | ErrorResponse>) {
|
||||
// all fields are optional in validation
|
||||
@@ -76,9 +78,30 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
|
||||
}
|
||||
}
|
||||
|
||||
export async function projectDownload(_req: Request, res: Response) {
|
||||
const fileTitle = projectService.getProjectTitle();
|
||||
res.download(resolveDbPath, `${fileTitle}.json`, (error) => {
|
||||
/**
|
||||
* Utility function finds the correct project file to download
|
||||
*/
|
||||
function selectProjectFile(fileName?: string) {
|
||||
const projectsDirectory = resolveDbDirectory;
|
||||
const fileToDownload = fileName ? ensureJsonExtension(fileName) : projectService.getProjectTitle();
|
||||
const pathToFile = join(projectsDirectory, fileToDownload);
|
||||
|
||||
return { pathToFile, name: fileToDownload };
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows downloading of a optionally given project files
|
||||
* If no {filename} is provided, loaded file will be served
|
||||
*/
|
||||
export async function projectDownload(req: Request, res: Response) {
|
||||
const { pathToFile, name } = selectProjectFile(req.body?.fileName);
|
||||
|
||||
// Check if the file exists before attempting to download
|
||||
if (!existsSync(pathToFile)) {
|
||||
return res.status(404).send({ message: `Project ${name} not found.` });
|
||||
}
|
||||
|
||||
res.download(pathToFile, name, (error) => {
|
||||
if (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(500).send({ message });
|
||||
|
||||
@@ -16,6 +16,7 @@ import { uploadProjectFile } from './db.middleware.js';
|
||||
import {
|
||||
projectSanitiser,
|
||||
sanitizeProjectFilename,
|
||||
validateDownloadProject,
|
||||
validateLoadProjectFile,
|
||||
validatePatchProjectFile,
|
||||
validateProjectDuplicate,
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
router.get('/download', projectDownload);
|
||||
router.post('/download', validateDownloadProject, projectDownload);
|
||||
router.post('/upload', uploadProjectFile, postProjectFile);
|
||||
|
||||
router.patch('/', validatePatchProjectFile, patchPartialProjectFile);
|
||||
|
||||
@@ -104,3 +104,19 @@ export const validateProjectRename = [
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description Validates a download request which can include an optional project name.
|
||||
*/
|
||||
export const validateDownloadProject = [
|
||||
body('fileName').isString().optional(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(422).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
|
||||
|
||||
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
|
||||
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
|
||||
@@ -46,8 +47,8 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
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);
|
||||
|
||||
if (patchEvent.custom && newObjectProperty.custom) {
|
||||
Object.assign(patchEvent.custom, newObjectProperty.custom);
|
||||
@@ -177,7 +178,13 @@ const actionHandlers: Record<string, ActionHandler> = {
|
||||
if (time === 0) {
|
||||
return { payload: 'success' };
|
||||
}
|
||||
runtimeService.addTime(time * 1000); //frontend is seconds based
|
||||
|
||||
const timeToAdd = time * MILLIS_PER_SECOND; // frontend is seconds based
|
||||
if (Math.abs(timeToAdd) > MILLIS_PER_HOUR) {
|
||||
throw new Error(`Payload too large: ${time}`);
|
||||
}
|
||||
|
||||
runtimeService.addTime(timeToAdd);
|
||||
return { payload: 'success' };
|
||||
},
|
||||
/* Extra timers */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OntimeEvent, 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';
|
||||
@@ -10,7 +11,7 @@ const whitelistedPayload = {
|
||||
note: coerceString,
|
||||
cue: coerceString,
|
||||
|
||||
duration: (value: unknown) => coerceNumber(value) * 1000,
|
||||
duration: (value: unknown) => Math.max(coerceNumber(value) * MILLIS_PER_SECOND, maxDuration),
|
||||
|
||||
isPublic: coerceBoolean,
|
||||
skip: coerceBoolean,
|
||||
@@ -22,14 +23,13 @@ const whitelistedPayload = {
|
||||
|
||||
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;
|
||||
return { custom: { [customKey]: parserFn(value) } };
|
||||
}
|
||||
|
||||
if (!isKeyOfType(property, whitelistedPayload)) {
|
||||
throw new Error(`Property ${property} not permitted`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -2,5 +2,5 @@ export const timerConfig = {
|
||||
skipLimit: 1000, // threshold of skip for recalculating
|
||||
updateRate: 32, // how often do we update the timer
|
||||
notificationRate: 1000, // how often do we notify clients and integrations
|
||||
triggerAhead: 16, // how far ahead do we trigger the end event
|
||||
triggerAhead: 10, // how far ahead do we trigger the end event
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OntimeEvent } from 'ontime-types';
|
||||
|
||||
import * as runtimeState from '../stores/runtimeState.js';
|
||||
import type { UpdateResult } from '../stores/runtimeState.js';
|
||||
import { timerConfig } from '../config/config.js';
|
||||
|
||||
/**
|
||||
* Service manages Ontime's main timer
|
||||
@@ -41,7 +42,7 @@ export class TimerService {
|
||||
}
|
||||
|
||||
const state = runtimeState.getState();
|
||||
const endTime = state.timer.current - 10;
|
||||
const endTime = state.timer.current - timerConfig.triggerAhead;
|
||||
this.endCallback = setTimeout(() => this.update(), endTime);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1424,7 +1424,7 @@ describe('getRuntimeOffset()', () => {
|
||||
expect(offset).toBe(-50);
|
||||
});
|
||||
|
||||
it('added time adds time ahead (positive offset)', () => {
|
||||
it('added time subtracts time offset (positive offset)', () => {
|
||||
const state = {
|
||||
eventNow: {
|
||||
id: '1',
|
||||
@@ -1444,7 +1444,7 @@ describe('getRuntimeOffset()', () => {
|
||||
} as RuntimeState;
|
||||
|
||||
const offset = getRuntimeOffset(state);
|
||||
expect(offset).toBe(-40);
|
||||
expect(offset).toBe(-60);
|
||||
});
|
||||
|
||||
it('considers running overtime (negative offset)', () => {
|
||||
|
||||
@@ -41,6 +41,7 @@ export class AuxTimerService {
|
||||
|
||||
@broadcastReturn
|
||||
pause() {
|
||||
this.stopInterval();
|
||||
return this.timer.pause(this.getTime());
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,24 @@ export class HttpIntegration implements IIntegration<HttpSubscription, HttpSetti
|
||||
|
||||
emit(path: string) {
|
||||
got.get(path, { retry: { limit: 0 } }).catch((err) => {
|
||||
logger.error(LogOrigin.Tx, `HTTP Integration: ${err.code}`);
|
||||
logger.warning(LogOrigin.Tx, `HTTP Integration: ${err.message}`);
|
||||
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' The server refused the connection`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.code === 'ENOTFOUND') {
|
||||
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' DNS lookup failed`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.code === 'ETIMEDOUT') {
|
||||
logger.warning(LogOrigin.Tx, `HTTP Integration: '${err.code}' The connection timed out`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warning(LogOrigin.Tx, `HTTP Integration: ${err.code}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -161,12 +161,13 @@ describe('generate()', () => {
|
||||
const testRundown: OntimeRundown = [
|
||||
{ type: SupportedEvent.Event, id: '1', timeStart: 100, timeEnd: 200 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '2', timeStart: 200, timeEnd: 300 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: 'skipped', skip: true, timeStart: 300, timeEnd: 400 } as OntimeEvent,
|
||||
{ type: SupportedEvent.Event, id: '3', timeStart: 400, timeEnd: 500 } as OntimeEvent,
|
||||
];
|
||||
|
||||
const initResult = generate(testRundown);
|
||||
expect(initResult.order.length).toBe(3);
|
||||
expect(initResult.totalDuration).toBe(400 - 100);
|
||||
expect(initResult.order.length).toBe(4);
|
||||
expect(initResult.totalDuration).toBe(500 - 100);
|
||||
});
|
||||
|
||||
it('calculates total duration across days with gap', () => {
|
||||
@@ -346,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 } 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';
|
||||
@@ -80,9 +80,12 @@ export function generate(
|
||||
links = {};
|
||||
firstStart = null;
|
||||
lastEnd = null;
|
||||
totalDuration = 0;
|
||||
totalDelay = 0;
|
||||
|
||||
let accumulatedDelay = 0;
|
||||
let daySpan = 0;
|
||||
let previousStart: MaybeNumber = null;
|
||||
let previousEnd: MaybeNumber = null;
|
||||
|
||||
for (let i = 0; i < initialRundown.length; i++) {
|
||||
@@ -99,17 +102,20 @@ export function generate(
|
||||
// update the persisted event
|
||||
initialRundown[i] = updatedEvent;
|
||||
|
||||
// update rundown duration
|
||||
if (firstStart === null) {
|
||||
firstStart = updatedEvent.timeStart;
|
||||
}
|
||||
lastEnd = updatedEvent.timeEnd;
|
||||
// we need to generate the skip event, but dont want to use its times
|
||||
if (!updatedEvent.skip) {
|
||||
// update rundown duration
|
||||
if (firstStart === null) {
|
||||
firstStart = updatedEvent.timeStart;
|
||||
}
|
||||
lastEnd = updatedEvent.timeEnd;
|
||||
|
||||
// check if we go over midnight, account for eventual gaps
|
||||
const gapOverMidnight = previousEnd !== null && previousEnd > updatedEvent.timeStart;
|
||||
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
|
||||
if (gapOverMidnight || durationOverMidnight) {
|
||||
daySpan++;
|
||||
// check if we go over midnight, account for eventual gaps
|
||||
const gapOverMidnight = previousStart !== null && checkIsNextDay(previousStart, updatedEvent.timeStart);
|
||||
const durationOverMidnight = updatedEvent.timeStart > updatedEvent.timeEnd;
|
||||
if (gapOverMidnight || durationOverMidnight) {
|
||||
daySpan++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +123,7 @@ export function generate(
|
||||
// !!! this must happen after handling the links
|
||||
if (isOntimeDelay(updatedEvent)) {
|
||||
accumulatedDelay += updatedEvent.duration;
|
||||
} else if (isOntimeEvent(updatedEvent)) {
|
||||
} else if (isOntimeEvent(updatedEvent) && !updatedEvent.skip) {
|
||||
const eventStart = updatedEvent.timeStart;
|
||||
|
||||
// we only affect positive delays (time forwards)
|
||||
@@ -126,6 +132,7 @@ export function generate(
|
||||
accumulatedDelay = Math.max(accumulatedDelay - gap, 0);
|
||||
}
|
||||
updatedEvent.delay = accumulatedDelay;
|
||||
previousStart = updatedEvent.timeStart;
|
||||
previousEnd = updatedEvent.timeEnd;
|
||||
}
|
||||
|
||||
@@ -256,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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EndAction, LogOrigin, OntimeEvent, Playback, RuntimeStore, TimerLifeCycle } from 'ontime-types';
|
||||
import { EndAction, LogOrigin, MaybeNumber, OntimeEvent, Playback, RuntimeStore, TimerLifeCycle } from 'ontime-types';
|
||||
import { millisToString, validatePlayback } from 'ontime-utils';
|
||||
|
||||
import { deepEqual } from 'fast-equals';
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
getPlayableEvents,
|
||||
} from '../rundown-service/rundownUtils.js';
|
||||
import { integrationService } from '../integration-service/IntegrationService.js';
|
||||
import { getForceUpdate, getShouldClockUpdate, getShouldTimerUpdate } from './rundownService.utils.js';
|
||||
|
||||
/**
|
||||
* Service manages runtime status of app
|
||||
@@ -27,15 +28,20 @@ import { integrationService } from '../integration-service/IntegrationService.js
|
||||
*/
|
||||
class RuntimeService {
|
||||
private eventTimer: TimerService | null = null;
|
||||
private lastOnUpdate = -1;
|
||||
private lastIntegrationClockUpdate = -1;
|
||||
private lastIntegrationTimerValue = -1;
|
||||
|
||||
/** last time we updated the socket */
|
||||
static previousUpdate: number;
|
||||
static previousTimerUpdate: number;
|
||||
static previousTimerValue: MaybeNumber;
|
||||
static previousClockUpdate: number;
|
||||
/** last known state */
|
||||
static previousState: RuntimeState;
|
||||
|
||||
constructor() {
|
||||
RuntimeService.previousUpdate = -1;
|
||||
RuntimeService.previousTimerUpdate = -1;
|
||||
RuntimeService.previousTimerValue = -1;
|
||||
RuntimeService.previousClockUpdate = -1;
|
||||
RuntimeService.previousState = {} as RuntimeState;
|
||||
}
|
||||
|
||||
@@ -61,19 +67,25 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
// update normal cycle
|
||||
if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) {
|
||||
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
|
||||
if (hasRunningTimer) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
});
|
||||
}
|
||||
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
|
||||
const shouldUpdateTimer =
|
||||
hasRunningTimer && getShouldTimerUpdate(this.lastIntegrationTimerValue, newState.timer.current);
|
||||
|
||||
if (shouldUpdateTimer) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onUpdate);
|
||||
});
|
||||
|
||||
this.lastIntegrationTimerValue = newState.timer.current;
|
||||
}
|
||||
|
||||
const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock);
|
||||
if (shouldUpdateClock) {
|
||||
process.nextTick(() => {
|
||||
integrationService.dispatch(TimerLifeCycle.onClock);
|
||||
});
|
||||
|
||||
this.lastOnUpdate = newState.clock;
|
||||
this.lastIntegrationClockUpdate = newState.clock;
|
||||
}
|
||||
|
||||
if (shouldCallRoll) {
|
||||
@@ -516,29 +528,30 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
// we do the comparison by explicitly for each property
|
||||
// to apply custom logic for different datasets
|
||||
|
||||
// some of the data, we only update at intervals
|
||||
const isTimeToUpdate =
|
||||
state.clock < RuntimeService.previousUpdate ||
|
||||
state.clock - RuntimeService.previousUpdate >= timerConfig.notificationRate;
|
||||
const shouldUpdateClock = getShouldClockUpdate(RuntimeService.previousClockUpdate, state.clock);
|
||||
const shouldForceTimerUpdate = getForceUpdate(RuntimeService.previousTimerUpdate, state.clock);
|
||||
const shouldUpdateTimer =
|
||||
shouldForceTimerUpdate || getShouldTimerUpdate(RuntimeService.previousTimerValue, state.timer.current);
|
||||
|
||||
// some changes need an immediate update
|
||||
const hasNewLoaded = state.eventNow?.id !== RuntimeService.previousState?.eventNow?.id;
|
||||
|
||||
const hasSkippedBack = state.clock < RuntimeService.previousUpdate;
|
||||
const justStarted = !RuntimeService.previousState?.timer;
|
||||
const hasChangedPlayback = RuntimeService.previousState.timer?.playback !== state.timer.playback;
|
||||
const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback;
|
||||
const hasImmediateChanges = hasNewLoaded || justStarted || hasChangedPlayback;
|
||||
|
||||
if (hasChangedPlayback) {
|
||||
eventStore.set('onAir', state.timer.playback !== Playback.Stop);
|
||||
}
|
||||
|
||||
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.timer, state.timer))) {
|
||||
if (hasImmediateChanges || (shouldUpdateTimer && !deepEqual(RuntimeService.previousState?.timer, state.timer))) {
|
||||
RuntimeService.previousTimerUpdate = state.clock;
|
||||
RuntimeService.previousTimerValue = state.timer.current;
|
||||
eventStore.set('timer', state.timer);
|
||||
RuntimeService.previousState.timer = { ...state.timer };
|
||||
}
|
||||
|
||||
if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.runtime, state.runtime))) {
|
||||
if (hasChangedPlayback || (shouldUpdateTimer && !deepEqual(RuntimeService.previousState?.runtime, state.runtime))) {
|
||||
eventStore.set('runtime', state.runtime);
|
||||
RuntimeService.previousState.runtime = { ...state.runtime };
|
||||
}
|
||||
@@ -549,8 +562,8 @@ function broadcastResult(_target: any, _propertyKey: string, descriptor: Propert
|
||||
updateEventIfChanged('eventNext', state);
|
||||
updateEventIfChanged('publicEventNext', state);
|
||||
|
||||
if (isTimeToUpdate) {
|
||||
RuntimeService.previousUpdate = state.clock;
|
||||
if (shouldUpdateClock) {
|
||||
RuntimeService.previousClockUpdate = state.clock;
|
||||
eventStore.set('clock', state.clock);
|
||||
saveRestoreState(state);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { getShouldClockUpdate, getShouldTimerUpdate } from '../rundownService.utils.js';
|
||||
|
||||
describe('getShouldClockUpdate()', () => {
|
||||
it('should return true when we slid forwards', () => {
|
||||
const previousUpdate = Date.now() - 2000; // 2 seconds ago
|
||||
const now = Date.now();
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when we slid backwards', () => {
|
||||
const previousUpdate = Date.now();
|
||||
const now = Date.now() - 2000; // 2 seconds ago
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when clock is a second ahead', () => {
|
||||
const previousUpdate = MILLIS_PER_MINUTE - 100;
|
||||
const now = MILLIS_PER_MINUTE;
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when clock is not a second ahead and force update is not required', () => {
|
||||
const previousUpdate = Date.now() - 32;
|
||||
const now = Date.now();
|
||||
const result = getShouldClockUpdate(previousUpdate, now);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShouldTimerUpdate', () => {
|
||||
it('should return false when currentValue is null', () => {
|
||||
const previousValue = 0;
|
||||
const currentValue = null;
|
||||
const result = getShouldTimerUpdate(previousValue, currentValue);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when timer is a second ahead', () => {
|
||||
const previousValue = 6500;
|
||||
const currentValue = 5000;
|
||||
const result = getShouldTimerUpdate(previousValue, currentValue);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when timer is not a second ahead', () => {
|
||||
const previousValue = 5500;
|
||||
const currentValue = 5200;
|
||||
const result = getShouldTimerUpdate(previousValue, currentValue);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('timer value is ceiled', () => {
|
||||
const previousValue = 5001; // 6
|
||||
const currentValue = 4999; // 5
|
||||
const result = getShouldTimerUpdate(previousValue, currentValue);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { millisToSeconds } from 'ontime-utils';
|
||||
|
||||
import { timerConfig } from '../../config/config.js';
|
||||
import { MaybeNumber } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* Checks whether we should update the clock value
|
||||
* - clock has slid
|
||||
* - we have rolled into a new seconds unit
|
||||
*/
|
||||
export function getShouldClockUpdate(previousUpdate: number, now: number): boolean {
|
||||
const shouldForceUpdate = getForceUpdate(previousUpdate, now);
|
||||
if (shouldForceUpdate) {
|
||||
return true;
|
||||
}
|
||||
const isClockSecondAhead = millisToSeconds(now) !== millisToSeconds(previousUpdate + timerConfig.triggerAhead);
|
||||
return isClockSecondAhead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether we should update the timer value
|
||||
* - we have rolled into a new seconds unit
|
||||
*/
|
||||
export function getShouldTimerUpdate(previousValue: number, currentValue: MaybeNumber): boolean {
|
||||
if (currentValue === null) {
|
||||
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);
|
||||
return shouldUpdateTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
* In some cases we want to force an update to the timer
|
||||
* - if the clock has slid back
|
||||
* - if we have escaped the update rate (clock slid forward)
|
||||
*/
|
||||
export function getForceUpdate(previousUpdate: number, now: number): boolean {
|
||||
const isClockBehind = now < previousUpdate;
|
||||
const hasExceededRate = now - previousUpdate >= timerConfig.notificationRate;
|
||||
return isClockBehind || hasExceededRate;
|
||||
}
|
||||
@@ -322,10 +322,10 @@ export function getRuntimeOffset(state: RuntimeState): MaybeNumber {
|
||||
const pausedTime = state._timer.pausedAt === null ? 0 : clock - state._timer.pausedAt;
|
||||
|
||||
// startOffset - difference between scheduled start and actual start
|
||||
// addedTime - time added by user (positive offset)
|
||||
// addedTime - time added by user (negative offset)
|
||||
// pausedTime - time the playback was paused (negative offset)
|
||||
// overtime - how long the timer has been over-running (negative offset)
|
||||
return startOffset + addedTime - pausedTime - overtime;
|
||||
return startOffset - addedTime - pausedTime - overtime;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -356,5 +356,5 @@ export function getExpectedEnd(state: RuntimeState): MaybeNumber {
|
||||
if (state.runtime.actualStart === null) {
|
||||
return null;
|
||||
}
|
||||
return state.runtime.plannedEnd + state.runtime.offset + state._timer.totalDelay;
|
||||
return state.runtime.plannedEnd - state.runtime.offset + state._timer.totalDelay;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,24 +162,28 @@ describe('mutation on runtimeState', () => {
|
||||
const firstStart = newState.clock;
|
||||
expect(newState.runtime.actualStart).toBe(newState.clock);
|
||||
expect(newState.runtime.offset).toBe(event1.timeStart - newState.clock);
|
||||
expect(newState.runtime.expectedEnd).toBe(newState.runtime.offset + event2.timeEnd);
|
||||
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset);
|
||||
|
||||
// 3. Next event
|
||||
load(event2, [event1, event2]);
|
||||
start();
|
||||
newState = getState();
|
||||
expect(newState.runtime.actualStart).toBe(firstStart);
|
||||
|
||||
// there is a case where the calculation time overflows the millisecond which makes
|
||||
// tests fail
|
||||
const forgivingActualStart = Math.abs(newState.runtime.actualStart - firstStart);
|
||||
expect(forgivingActualStart).toBeLessThanOrEqual(1);
|
||||
// we are over-under, the difference between the schedule and the actual start
|
||||
const delayBefore = event2.timeStart - newState.clock;
|
||||
expect(newState.runtime.offset).toBe(delayBefore);
|
||||
// finish is the difference between the runtime and the schedule
|
||||
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset);
|
||||
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset);
|
||||
|
||||
// 4. Add time
|
||||
addTime(10);
|
||||
newState = getState();
|
||||
expect(newState.runtime.offset).toBe(delayBefore + 10);
|
||||
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd + newState.runtime.offset);
|
||||
expect(newState.runtime.offset).toBe(delayBefore - 10);
|
||||
expect(newState.runtime.expectedEnd).toBe(event2.timeEnd - newState.runtime.offset);
|
||||
|
||||
// 5. Stop event
|
||||
stop();
|
||||
|
||||
@@ -23,7 +23,7 @@ const initialRuntime: Runtime = {
|
||||
plannedEnd: 0,
|
||||
actualStart: null,
|
||||
expectedEnd: null,
|
||||
};
|
||||
} as const;
|
||||
|
||||
const initialTimer: TimerState = {
|
||||
addedTime: 0,
|
||||
@@ -35,7 +35,7 @@ const initialTimer: TimerState = {
|
||||
playback: Playback.Stop,
|
||||
secondaryTimer: null,
|
||||
startedAt: null,
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type RuntimeState = {
|
||||
clock: number; // realtime clock
|
||||
@@ -47,6 +47,7 @@ export type RuntimeState = {
|
||||
timer: TimerState;
|
||||
// private properties of the timer calculations
|
||||
_timer: {
|
||||
forceFinish: MaybeNumber;
|
||||
totalDelay: number; // this value comes from rundown service
|
||||
pausedAt: MaybeNumber;
|
||||
secondaryTarget: MaybeNumber;
|
||||
@@ -59,9 +60,10 @@ const runtimeState: RuntimeState = {
|
||||
publicEventNow: null,
|
||||
eventNext: null,
|
||||
publicEventNext: null,
|
||||
runtime: initialRuntime,
|
||||
runtime: { ...initialRuntime },
|
||||
timer: { ...initialTimer },
|
||||
_timer: {
|
||||
forceFinish: null,
|
||||
totalDelay: 0,
|
||||
pausedAt: null,
|
||||
secondaryTarget: null,
|
||||
@@ -78,12 +80,10 @@ export function clear() {
|
||||
runtimeState.eventNext = null;
|
||||
runtimeState.publicEventNext = null;
|
||||
|
||||
runtimeState.runtime = {
|
||||
...initialRuntime,
|
||||
// persist session stuff
|
||||
actualStart: runtimeState.runtime.actualStart,
|
||||
numEvents: runtimeState.runtime.numEvents,
|
||||
};
|
||||
runtimeState.runtime.offset = null;
|
||||
runtimeState.runtime.actualStart = null;
|
||||
runtimeState.runtime.expectedEnd = null;
|
||||
runtimeState.runtime.selectedEventIndex = null;
|
||||
|
||||
runtimeState.timer.playback = Playback.Stop;
|
||||
runtimeState.clock = clock.timeNow();
|
||||
@@ -301,7 +301,7 @@ export function start(state: RuntimeState = runtimeState): boolean {
|
||||
}
|
||||
|
||||
state.runtime.offset = getRuntimeOffset(state);
|
||||
state.runtime.expectedEnd = state.runtime.plannedEnd + state.runtime.offset;
|
||||
state.runtime.expectedEnd = state.runtime.plannedEnd - state.runtime.offset;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -333,15 +333,14 @@ export function addTime(amount: number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.expectedFinish += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
|
||||
// handle edge cases
|
||||
// !!! we need to handle side effects before updating the state
|
||||
const willGoNegative = amount < 0 && Math.abs(amount) > runtimeState.timer.current;
|
||||
const hasFinished = runtimeState.timer.finishedAt !== null;
|
||||
|
||||
if (willGoNegative && !hasFinished) {
|
||||
runtimeState.timer.finishedAt = clock.timeNow();
|
||||
// set finished time so side effects are triggered
|
||||
runtimeState._timer.forceFinish = clock.timeNow();
|
||||
} else {
|
||||
const willGoPositive = runtimeState.timer.current < 0 && runtimeState.timer.current + amount > 0;
|
||||
if (willGoPositive) {
|
||||
@@ -349,6 +348,11 @@ export function addTime(amount: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// we can update the state after handling the side effects
|
||||
runtimeState.timer.addedTime += amount;
|
||||
runtimeState.timer.expectedFinish += amount;
|
||||
runtimeState.timer.current += amount;
|
||||
|
||||
// update runtime delays: over - under
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
@@ -385,6 +389,7 @@ export function update(): UpdateResult {
|
||||
|
||||
// update offset
|
||||
runtimeState.runtime.offset = getRuntimeOffset(runtimeState);
|
||||
runtimeState.runtime.expectedEnd = getExpectedEnd(runtimeState);
|
||||
|
||||
return {
|
||||
hasTimerFinished,
|
||||
@@ -407,16 +412,21 @@ export function update(): UpdateResult {
|
||||
function onPlayUpdate() {
|
||||
let isFinished = false;
|
||||
runtimeState.timer.current = getCurrent(runtimeState);
|
||||
const shouldForceFinish = runtimeState._timer.forceFinish !== null;
|
||||
const finishedNow =
|
||||
runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null;
|
||||
shouldForceFinish ||
|
||||
(runtimeState.timer.current <= timerConfig.triggerAhead && runtimeState.timer.finishedAt === null);
|
||||
|
||||
if (runtimeState.timer.playback === Playback.Play && finishedNow) {
|
||||
runtimeState.timer.finishedAt = runtimeState.clock;
|
||||
runtimeState.timer.finishedAt = runtimeState._timer.forceFinish ?? runtimeState.clock;
|
||||
isFinished = true;
|
||||
} else {
|
||||
runtimeState.timer.expectedFinish = getExpectedFinish(runtimeState);
|
||||
}
|
||||
|
||||
if (shouldForceFinish) {
|
||||
runtimeState._timer.forceFinish = null;
|
||||
}
|
||||
runtimeState.timer.elapsed = runtimeState.timer.duration - runtimeState.timer.current;
|
||||
|
||||
return { isFinished };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import { dbModel } from '../../models/dataModel.js';
|
||||
import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js';
|
||||
import { makeString } from '../parserUtils.js';
|
||||
import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js';
|
||||
import { ImportMap } from 'ontime-utils';
|
||||
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import * as cache from '../../services/rundown-service/rundownCache.js';
|
||||
|
||||
describe('test json parser with valid def', () => {
|
||||
@@ -1515,4 +1515,131 @@ describe('parseExcel()', () => {
|
||||
expect((rundown[sixthId] as OntimeEvent).timeStart).toEqual((rundown[fourthId] as OntimeEvent).timeEnd);
|
||||
expect((rundown[sixthId] as OntimeEvent).linkStart).toEqual((rundown[fourthId] as OntimeEvent).id);
|
||||
});
|
||||
|
||||
it('#971 BUG: parses time fields and booleans', () => {
|
||||
const testData = [
|
||||
[
|
||||
'Cue',
|
||||
'Colour',
|
||||
'Time Start',
|
||||
'Time End',
|
||||
'Duration',
|
||||
'Link Start',
|
||||
'Title',
|
||||
'Note',
|
||||
'Timer Type',
|
||||
'End Action',
|
||||
'Warning time',
|
||||
'Danger time',
|
||||
'Public',
|
||||
'Skip',
|
||||
],
|
||||
[
|
||||
'SETUP',
|
||||
'',
|
||||
'1899-12-30T07:15:00.000Z',
|
||||
'1899-12-30T08:30:00.000Z',
|
||||
'',
|
||||
'false',
|
||||
'Setup',
|
||||
'',
|
||||
'time-to-end',
|
||||
'none',
|
||||
'15',
|
||||
'00:05:00',
|
||||
'FALSE',
|
||||
'TRUE',
|
||||
],
|
||||
[
|
||||
'MEET1',
|
||||
'#779BE7',
|
||||
'1899-12-30T08:30:00.000Z',
|
||||
'1899-12-30T10:00:00.000Z',
|
||||
'',
|
||||
'false',
|
||||
'Meeting 1',
|
||||
'',
|
||||
'time-to-end',
|
||||
'none',
|
||||
15,
|
||||
'00:05:00',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
],
|
||||
[
|
||||
'MEET2',
|
||||
'#779BE7',
|
||||
'1899-12-30T10:00:00.000Z',
|
||||
'',
|
||||
'60',
|
||||
'false',
|
||||
'Meeting 2',
|
||||
'',
|
||||
'time-to-end',
|
||||
'none',
|
||||
'13',
|
||||
'5',
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
],
|
||||
[
|
||||
'lunch',
|
||||
'#77C785',
|
||||
'',
|
||||
'1899-12-30T11:30:00.000Z',
|
||||
'',
|
||||
'true',
|
||||
'Lunch',
|
||||
'',
|
||||
'time-to-end',
|
||||
'none',
|
||||
13,
|
||||
5,
|
||||
'FALSE',
|
||||
'FALSE',
|
||||
],
|
||||
[
|
||||
'MEET3',
|
||||
'#779BE7',
|
||||
'1899-12-30T11:30:00.000Z',
|
||||
'',
|
||||
90,
|
||||
false,
|
||||
'Meeting 3',
|
||||
'',
|
||||
'count-up',
|
||||
'none',
|
||||
'11',
|
||||
5,
|
||||
'TRUE',
|
||||
'FALSE',
|
||||
],
|
||||
['MEET4', '#779BE7', '', '', 30, true, 'Meeting 4', '', 'count-up', 'none', 11, '00:05:00', 'TRUE', 'FALSE'],
|
||||
];
|
||||
|
||||
const parsedData = parseExcel(testData);
|
||||
const { rundown } = parsedData;
|
||||
|
||||
// elements in bug report
|
||||
// 15 is a number, in which case we parse it as a minutes value
|
||||
expect((rundown.at(1) as OntimeEvent).timeWarning).toBe(15 * MILLIS_PER_MINUTE);
|
||||
|
||||
// in the case where a string is passed, we need to check whether it is an ISO 8601 date
|
||||
expect((rundown.at(2) as OntimeEvent).duration).toBe(60 * MILLIS_PER_MINUTE);
|
||||
expect((rundown.at(2) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
expect((rundown.at(3) as OntimeEvent).timeWarning).toBe(13 * MILLIS_PER_MINUTE);
|
||||
expect((rundown.at(3) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
expect((rundown.at(4) as OntimeEvent).duration).toBe(90 * MILLIS_PER_MINUTE);
|
||||
expect((rundown.at(4) as OntimeEvent).linkStart).toBe(false);
|
||||
expect((rundown.at(4) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE);
|
||||
expect((rundown.at(4) as OntimeEvent).timeDanger).toBe(5 * MILLIS_PER_MINUTE);
|
||||
|
||||
expect((rundown.at(5) as OntimeEvent).duration).toBe(30 * MILLIS_PER_MINUTE);
|
||||
|
||||
// if we get a boolean, we should just use that
|
||||
expect((rundown.at(5) as OntimeEvent).linkStart).toBe(true);
|
||||
expect((rundown.at(5) as OntimeEvent).timeWarning).toBe(11 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { parseExcelDate } from '../time.js';
|
||||
|
||||
describe('parseExcelDate', () => {
|
||||
@@ -46,8 +47,18 @@ describe('parseExcelDate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('uses numeric fields as minutes', () => {
|
||||
const invalidFields = [1, 10, 100];
|
||||
invalidFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
expect(millis).toBe(field * MILLIS_PER_MINUTE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('returns 0 on other strings', () => {
|
||||
const invalidFields = ['10', 'test', ''];
|
||||
const invalidFields = ['test', ''];
|
||||
invalidFields.forEach((field) => {
|
||||
it(`handles ${field}`, () => {
|
||||
const millis = parseExcelDate(field);
|
||||
|
||||
@@ -19,10 +19,9 @@ export function ensureDirectory(directory: string): void {
|
||||
/**
|
||||
* Ensures that a filename ends with .json extension
|
||||
*/
|
||||
export function ensureJsonExtension(filename: string): string {
|
||||
export function ensureJsonExtension(filename: string | undefined): string | undefined {
|
||||
if (!filename) return filename;
|
||||
|
||||
return filename.includes('.json') ? filename : `${filename}.json`;
|
||||
return filename.endsWith('.json') ? filename : `${filename}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type ExcelData = Pick<DatabaseModel, 'rundown' | 'customFields'> & {
|
||||
};
|
||||
|
||||
function parseBooleanString(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// falsy values would be nullish or empty string
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { isTimeString } from 'ontime-utils';
|
||||
|
||||
const mts = 1000; // millis to seconds
|
||||
const mtm = 1000 * 60; // millis to minutes
|
||||
const mth = 1000 * 60 * 60; // millis to hours
|
||||
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';
|
||||
export const timeFormatSeconds = 'HH:mm:ss';
|
||||
@@ -13,110 +10,39 @@ 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 * mth + m * mtm + s * mts;
|
||||
};
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @param {boolean} fillLeft - autofill left = hours / right = seconds
|
||||
* @returns {number} - time string in millis
|
||||
*/
|
||||
export const forgivingStringToMillis = (value: string, fillLeft: boolean = true): number => {
|
||||
let millis = 0;
|
||||
|
||||
// check for AM/PM indicators
|
||||
const { sansPostfix, pastNoon } = stripAMPM(value);
|
||||
|
||||
//if past noon indicated add 12 hours
|
||||
if (pastNoon) {
|
||||
millis = mth * 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) * mth;
|
||||
millis += parse(second) * mtm;
|
||||
millis += parse(third) * mts;
|
||||
} 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) * mth;
|
||||
millis += parse(minutes) * mtm;
|
||||
millis += parse(seconds) * mts;
|
||||
} else {
|
||||
// otherwise lets treat as [minutes]
|
||||
millis += parse(first) * mtm;
|
||||
}
|
||||
}
|
||||
if (first != null && second != null && third == null) {
|
||||
// if string has two sections
|
||||
if (fillLeft) {
|
||||
// treat as [hours] [minutes]
|
||||
millis += parse(first) * mth;
|
||||
millis += parse(second) * mtm;
|
||||
} else {
|
||||
// treat as [minutes] [seconds]
|
||||
millis += parse(first) * mtm;
|
||||
millis += parse(second) * mts;
|
||||
}
|
||||
}
|
||||
return millis;
|
||||
};
|
||||
return h * MILLIS_PER_HOUR + m * MILLIS_PER_MINUTE + s * MILLIS_PER_SECOND;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
} else if (typeof excelDate === 'string') {
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
} else if (isTimeString(excelDate)) {
|
||||
return forgivingStringToMillis(excelDate);
|
||||
}
|
||||
|
||||
if (typeof excelDate === 'string') {
|
||||
if (isISO8601(excelDate)) {
|
||||
const date = new Date(excelDate);
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
return dateToMillis(date);
|
||||
}
|
||||
}
|
||||
return parseUserTime(excelDate);
|
||||
}
|
||||
|
||||
// if the user uses a number value eg. 15, excel could format the cell as number
|
||||
if (typeof excelDate === 'number') {
|
||||
return excelDate * MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
+1
-3
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "3.0.0",
|
||||
"version": "3.0.4",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"lightdev",
|
||||
"ontime",
|
||||
"timer",
|
||||
"rundown"
|
||||
@@ -30,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ export {
|
||||
swapEventData,
|
||||
} from './src/rundown-utils/rundownUtils.js';
|
||||
|
||||
// format utils
|
||||
// time format utils
|
||||
export {
|
||||
dayInMs,
|
||||
maxDuration,
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
@@ -43,11 +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';
|
||||
|
||||
// time utils
|
||||
export { dayInMs, mts } from './src/timeConstants.js';
|
||||
export { splitWhitespace } from './src/regex-utils/splitWhitespace.js';
|
||||
|
||||
// helpers from externals
|
||||
export { deepmerge } from './src/externals/deepmerge.js';
|
||||
@@ -65,6 +66,9 @@ export { validateEndAction, validateTimerType } from './src/validate-events/vali
|
||||
|
||||
// feature business logic
|
||||
|
||||
// feature business logic - rundown
|
||||
export { checkIsNextDay } from './src/date-utils/checkIsNextDay.js';
|
||||
|
||||
// feature business logic - spreadsheet import
|
||||
export {
|
||||
type ImportCustom,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"ontime-types": "workspace:*",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.4.3",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"sideEffects": false
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Utility function checks whether a given event is the day after from its predecessor
|
||||
* We consider an event to be the day after, if it begins before the start of the previous
|
||||
* @example day after
|
||||
* 09:00 - 10:00
|
||||
* 08:00 - 10:30
|
||||
* @example same day
|
||||
* 09:00 - 10:00
|
||||
* 09:30 - 10:30
|
||||
*/
|
||||
export function checkIsNextDay(previousStart: number, timeStart: number): boolean {
|
||||
return timeStart <= previousStart;
|
||||
}
|
||||
@@ -4,8 +4,11 @@ export const MILLIS_PER_SECOND = 1000;
|
||||
export const MILLIS_PER_MINUTE = 1000 * 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) {
|
||||
if (millis == null || millis === 0) {
|
||||
if (!millis) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isTimeString } from './isTimeString';
|
||||
import { isISO8601, isTimeString } from './isTimeString';
|
||||
|
||||
describe('test isTimeString() function', () => {
|
||||
it('it validates time strings', () => {
|
||||
@@ -33,3 +33,16 @@ describe('test isTimeString() function handle AM/PM', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isISO8601()', () => {
|
||||
it('returns true for valid ISO 8601 date-time strings', () => {
|
||||
expect(isISO8601('1899-12-30T08:30:00.000Z')).toBe(true);
|
||||
expect(isISO8601('2022-01-01T00:00:00.000Z')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for invalid ISO 8601 date-time strings', () => {
|
||||
expect(isISO8601('not a date')).toBe(false);
|
||||
expect(isISO8601('1899-12-30T08:30:00Z')).toBe(false); // missing milliseconds
|
||||
expect(isISO8601('1899-12-30 08:30:00.000Z')).toBe(false); // space instead of 'T'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* @description Validates a time string
|
||||
* @param {string} text - time string "23:00:12"
|
||||
* @returns {boolean} string represents time
|
||||
*/
|
||||
export const isTimeString = (text: string): boolean => {
|
||||
export function isTimeString(text: string): boolean {
|
||||
const regex = /^(?:(?:([01]?\d|2[0-3])[:,.])?([0-5]?\d)[:,.])?([0-5]?\d)?(\s)?([APap][Mm])?$/;
|
||||
return regex.test(text);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Validates a ISO8601 date-time string
|
||||
*/
|
||||
export function isISO8601(text: string): boolean {
|
||||
const regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
return regex.test(text);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user