Compare commits

...

77 Commits

Author SHA1 Message Date
jwetzell 8d526c7d31 Merge pull request #1019 from cpvalente/cleanup-server-setup
remove redundant path evaluation
2024-05-30 07:57:23 -05:00
Carlos Valente 686c6108bf refactor: improve payload type for OSC (#954)
---------

Co-authored-by: Joel Wetzell <jwetzell@yahoo.com>
Co-authored-by: arc-alex <ac@omnivox.dk>
2024-05-30 13:02:22 +02:00
Carlos Valente ea3d33ca93 fix: correct key on shortcuts 2024-05-30 12:15:57 +02:00
jwetzell 6bf94ff645 remove redundant path evaluation 2024-05-29 16:25:15 -05:00
Carlos Valente 930cd71ffd chore: upgrade electron 2024-05-29 08:33:44 +02:00
Carlos Valente 45cf1cf199 chore: bump version to 3.0.4 2024-05-28 22:49:29 +02:00
Carlos Valente 354f464263 chore: user feedback 2024-05-28 22:49:29 +02:00
Carlos Valente c46a00febc chore: expand download links 2024-05-28 22:49:29 +02:00
Carlos Valente e991e151db refactor: improve dnd activation strategy 2024-05-28 22:45:10 +02:00
Carlos Valente b5993b91e1 fix: column reordering
refactor: migrate local storage hook
2024-05-28 22:45:10 +02:00
arc-alex 4beac19c4a Clear upload folder on startup 2024-05-28 22:44:55 +02:00
Carlos Valente be852f4546 refactor: allow hiding sidebar 2024-05-27 15:52:02 +02:00
Carlos Valente 84e224dd71 refactor: unify time parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente 7cbb2112ed refactor: simplify parsing logic 2024-05-27 15:51:45 +02:00
Carlos Valente ce910c9cc6 refactor: extract parseUserTime 2024-05-27 15:51:45 +02:00
Alex Christoffer Rasmussen 9d04954d87 fix change api isPublic (#1008) 2024-05-27 13:01:10 +02:00
Alex Christoffer Rasmussen 345ea6722e add notes to secondaryOptions in timer view (#1004) 2024-05-27 12:22:01 +02:00
Alex Christoffer Rasmussen f43221cf7a don't notify when aux timer is paused (#1003) 2024-05-27 11:51:07 +02:00
Carlos Valente a09aa922bb refactor: delete several events 2024-05-26 21:36:49 +02:00
Carlos Valente 16f31a07b7 chore: upgrade dependencies 2024-05-26 21:36:49 +02:00
Carlos Valente 77d111650a refactor: reduce scope of context 2024-05-26 21:36:49 +02:00
jwetzell 72891a69f4 Merge pull request #998 from cpvalente/fix/smart-time-parsing
address some issues in am/pm time parsing
2024-05-26 11:07:43 -05:00
Carlos Valente b8d5a30caf style: input text alignment 2024-05-25 15:19:01 +02:00
jwetzell 9cbd4c085b slim back tests even further and list out 2024-05-24 06:51:51 -05:00
jwetzell afe0323384 use let not var 2024-05-24 06:12:46 -05:00
jwetzell 1907abc512 pare back am/pm tests 2024-05-24 05:53:07 -05:00
jwetzell 834aafe06d remove need for hardcoded 12am test 2024-05-23 20:29:05 -05:00
jwetzell 98418014d3 lowercase time string before beginning to parse 2024-05-23 20:16:41 -05:00
jwetzell 3d13e784ea fix parsing am/pm times 2024-05-23 19:45:12 -05:00
jwetzell e397500d07 dynamically build am/pm tests 2024-05-23 19:17:36 -05:00
jwetzell c7c0241859 add new am/pm test cases for dateConfig util 2024-05-23 17:39:42 -05:00
Carlos Valente 71a7fe96d1 style: fix input overflow 2024-05-23 22:13:42 +02:00
jwetzell d0f958c13d Merge pull request #990 from cpvalente/fix/docker-build
fix docker multi-architecture build
2024-05-23 08:15:07 -05:00
jwetzell 7ee98fb062 remove node setup and build from docker action 2024-05-21 16:06:28 -05:00
jwetzell c2b66a4215 rework dockerfile to remove need for turbo 2024-05-21 16:06:15 -05:00
jwetzell 1d0ee10c00 remove build:docker from turbo 2024-05-21 16:05:49 -05:00
Carlos Valente 248c952ec7 chore: bump version to 3.0.3 2024-05-21 20:59:58 +02:00
jwetzell 3cda6b222e Merge pull request #983 from thelan/docker-multistage
Multistage docker build
2024-05-21 08:10:21 -05:00
Julien Recurt d161411416 Updating Dockerfile for multistage build based on feedbacks 2024-05-21 11:27:02 +02:00
Leon Eckardt 97b8e6a290 fix #984 Expected End of timeline is not calculated correctly (#985) 2024-05-20 22:28:23 +02:00
Carlos Valente 036d4051fc fix: account for skip in rundown calculations 2024-05-20 22:13:28 +02:00
Carlos Valente 7cefe07594 refactor: cap added time 2024-05-20 21:17:37 +02:00
Carlos Valente d05cc88748 refactor: unify time constants 2024-05-20 21:17:37 +02:00
Carlos Valente 119e6caca3 refactor: cap timer values 2024-05-20 21:17:37 +02:00
Carlos Valente bb199128aa style: rundown takes available space 2024-05-20 21:12:31 +02:00
Carlos Valente dade6f6692 style: tweaks to shortcut panel 2024-05-20 21:12:31 +02:00
Carlos Valente 6bedd7ab1d style: consistent spacing in editor 2024-05-20 21:12:31 +02:00
Carlos Valente eb2c4d0780 refactor: recover day after indicator 2024-05-20 21:11:52 +02:00
Carlos Valente 4069038ed5 chore: forgiving test 2024-05-20 19:32:28 +02:00
Carlos Valente ac430602b4 chore: upgrade vite dependencies 2024-05-20 19:32:28 +02:00
Carlos Valente 082449bede fix: handle finish when adding time 2024-05-20 19:32:28 +02:00
Carlos Valente cfbd540a14 style: expose bg override variable 2024-05-20 19:29:57 +02:00
Julien Recurt a431fab3c4 exclude compose generated volumes directory 2024-05-19 21:16:39 +02:00
Julien Recurt 770d975610 Update DEVELOPMENT.md 2024-05-19 20:44:42 +02:00
Julien Recurt 6f95aac798 Adding multistage build 2024-05-19 20:40:35 +02:00
Carlos Valente dae6f84af0 fix: excel parsing 2024-05-17 19:44:47 +02:00
Carlos Valente ad46fac2d7 chore: add tests to support docs 2024-05-15 21:28:02 +02:00
Carlos Valente 37786c3528 chore: bump version to 3.0.2 2024-05-13 14:15:18 +02:00
Carlos Valente c6d36d2497 refactor: forgiving test 2024-05-13 08:25:57 +02:00
Carlos Valente 2394eced93 refactor: forgiving test 2024-05-13 08:08:43 +02:00
Carlos Valente 13aeeb278d fix: filename may not exist 2024-05-13 08:08:43 +02:00
Carlos Valente af5d6e1aee fix: prevent stale selection 2024-05-13 08:06:28 +02:00
Carlos Valente bc036bdae7 refactor: forviging test
Allow a scenario where the operation execution happens in the next millisecond
2024-05-12 20:15:59 +02:00
Carlos Valente 266a84e5d3 chore: update keywords 2024-05-12 20:15:59 +02:00
Carlos Valente 92c3148e49 chore: bump version to 3.0.1 2024-05-12 20:15:59 +02:00
Carlos Valente efd05bcc34 refactor: simplify overview logic 2024-05-12 17:16:33 +02:00
Carlos Valente 3c7e061e51 refactor: improve detection of day after
fix: prevent mutation of initial object

refactor: download project file

Update apps/client/src/features/rundown/event-block/EventBlock.utils.ts

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>

Update apps/client/src/features/rundown/event-block/__tests__/EventBlock.utils.test.ts

Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
2024-05-12 17:16:33 +02:00
Carlos Valente 07074260a4 refactor: improve integration updates 2024-05-12 17:03:13 +02:00
Carlos Valente c57b0423af refactor: leading updates in timers 2024-05-12 17:03:13 +02:00
Carlos Valente 52145d5e1e chore: update element order 2024-05-11 14:58:28 +02:00
Alex Christoffer Rasmussen b2668e61a5 refactor: improve logging from http integration (#946) 2024-05-11 14:23:07 +02:00
Carlos Valente 7da3480dfe style: improve readability of shortcuts 2024-05-11 14:22:29 +02:00
Carlos Valente e8cfdbe3b9 refactor: simplify tests 2024-05-11 14:22:29 +02:00
Carlos Valente fd052355c3 style: uniform button size 2024-05-11 14:22:29 +02:00
Carlos Valente 00f04fb78c refactor: download project file 2024-05-10 22:35:12 +02:00
Carlos Valente 6f80de58ca fix: prevent mutation of initial object 2024-05-10 21:59:01 +02:00
Carlos Valente 5977266332 chore: update readme 2024-05-07 21:34:49 +02:00
109 changed files with 2299 additions and 1332 deletions
+9
View File
@@ -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

-18
View File
@@ -17,24 +17,6 @@ jobs:
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project packages
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: pnpm turbo build:docker
- name: Docker Login
uses: docker/login-action@v2.1.0
with:
-2
View File
@@ -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
View File
@@ -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
+36 -47
View File
@@ -4,19 +4,22 @@
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
## Download the latest releases here
<div style="display: flex; justify-content: space-around">
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg"><img alt="Download MacOS" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/mac-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe"><img alt="Download Windows" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/win-download.png"/></a>
<a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage"><img alt="Download Linux" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/linux-download.png"/></a>
<a href="https://hub.docker.com/r/getontime/ontime"><img alt="Get from Dockerhub" src="https://github.com/cpvalente/ontime/blob/master/.github/aux-images/dockerhub.png"/></a>
</div>
<br />
## Download the latest release
<a href="https://www.buymeacoffee.com/cpvalente" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="32"></a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-win64.exe">Windows</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
## Using Ontime?
Let us know!
Ontime improves from the collaboration with its users. We would like to understand how you use Ontime and appreciate your feedback.
We would also like to include a testimonials section in our ✨new website✨. It would be great to showcase the diversity of users running Ontime.
# Ontime
Ontime is a browser-based application that manages event rundowns, scheduling, and cueing.
@@ -24,6 +27,7 @@ Ontime is a browser-based application that manages event rundowns, scheduling, a
With Ontime, you can plan, track your schedule, manage automation and cross-department show information all in one place.
Ontime is made by entertainment and broadcast engineers and used by
- Conference organisers
- Touring shows and receiving venues
- Broadcasters and streamers
@@ -99,16 +103,16 @@ More documentation is available [in our docs](https://docs.getontime.no)
## Main features
* [x] Distribute data over network and render it in the browser
* [x] Collaborative
* [x] Extendable
* [x] Send messages to different screen types
* [x] Differentiate between backstage and public data
* [x] Workflow for managing delays
* [x] Rich protocol integrations for Control and Feedback
* [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
* [x] Multi-platform (available on Windows, MacOS and Linux)
* [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
- [x] Distribute data over network and render it in the browser
- [x] Collaborative
- [x] Extendable
- [x] Send messages to different screen types
- [x] Differentiate between backstage and public data
- [x] Workflow for managing delays
- [x] Rich protocol integrations for Control and Feedback
- [x] For servers: use docker to run Ontime in in a server, configure from a browser anywhere
- [x] Multi-platform (available on Windows, MacOS and Linux)
- [x] Companion integration [follow link](https://bitfocus.io/connections/getontime-ontime)
## Roadmap
@@ -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).
+5 -5
View File
@@ -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"
}
}
+1 -4
View File
@@ -3,7 +3,6 @@ import { ChakraProvider } from '@chakra-ui/react';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ContextMenu } from './common/components/context-menu/ContextMenu';
import ErrorBoundary from './common/components/error-boundary/ErrorBoundary';
import { AppContextProvider } from './common/context/AppContext';
import { ontimeQueryClient } from './common/queryClient';
@@ -25,9 +24,7 @@ function App() {
<div className='App'>
<ErrorBoundary>
<TranslationProvider>
<ContextMenu>
<AppRouter />
</ContextMenu>
<AppRouter />
</TranslationProvider>
</ErrorBoundary>
<ReactQueryDevtools initialIsOpen={false} />
+4 -4
View File
@@ -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'];
+2 -2
View File
@@ -74,8 +74,8 @@ export async function requestApplyDelay(eventId: string): Promise<AxiosResponse<
/**
* HTTP request to delete given event
*/
export async function requestDelete(eventId: string): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(`${rundownPath}/${eventId}`);
export async function requestDelete(eventIds: string[]): Promise<AxiosResponse<MessageResponse>> {
return axios.delete(rundownPath, { data: { ids: eventIds } });
}
/**
@@ -75,6 +75,7 @@ export const ContextMenu = ({ children }: ContextMenuProps) => {
w={1}
h={1}
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
}}
@@ -1,9 +1,8 @@
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEventAction } from '../../../hooks/useEventAction';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import style from './DelayInput.module.scss';
@@ -40,7 +39,7 @@ export default function DelayInput(props: DelayInputProps) {
}
const isNegative = newValue.startsWith('-');
let newMillis = forgivingStringToMillis(newValue);
let newMillis = parseUserTime(newValue);
if (isNegative) {
newMillis = newMillis * -1;
@@ -1,23 +1,21 @@
import { FocusEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { Input } from '@chakra-ui/react';
import { millisToString } from 'ontime-utils';
import { millisToString, parseUserTime } from 'ontime-utils';
import { useEmitLog } from '../../../stores/logger';
import { forgivingStringToMillis } from '../../../utils/dateConfig';
import { cx } from '../../../utils/styleUtils';
import style from './TimeInput.module.scss';
interface TimeInputProps<T extends string> {
name: T;
submitHandler: (field: T, value: string) => void;
time?: number;
placeholder: string;
disabled?: boolean;
align?: 'left' | 'center';
className?: string;
}
export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
const { name, submitHandler, time = 0, placeholder, disabled, className } = props;
const { name, submitHandler, time = 0, placeholder, disabled, align = 'center', className } = props;
const { emitError } = useEmitLog();
const inputRef = useRef<HTMLInputElement | null>(null);
const [value, setValue] = useState<string>('');
@@ -58,7 +56,7 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
submitHandler(name, newValue);
}
const valueInMillis = forgivingStringToMillis(newValue);
const valueInMillis = parseUserTime(newValue);
if (valueInMillis === time) {
return false;
}
@@ -117,15 +115,13 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
resetValue();
}, [resetValue, time]);
const timeInputClasses = cx([style.timeInput, className]);
return (
<Input
disabled={disabled}
size='sm'
ref={inputRef}
data-testid={`time-input-${name}`}
className={timeInputClasses}
className={className}
fontSize='1rem'
type='text'
placeholder={placeholder}
@@ -136,7 +132,10 @@ export default function TimeInput<T extends string>(props: TimeInputProps<T>) {
onKeyDown={onKeyDownHandler}
value={value}
maxLength={8}
maxWidth='7.5em'
letterSpacing='1px'
autoComplete='off'
textAlign={align}
/>
);
}
@@ -6,7 +6,3 @@ $input-delayed-border-color: $ontime-delay-text;
border: 1px solid $input-delayed-border-color;
}
}
.inputField {
max-width: 7.75em;
}
@@ -28,7 +28,7 @@ export default function TimeInputWithButton<T extends string>(props: PropsWithCh
submitHandler={submitHandler}
time={time}
placeholder={placeholder}
className={style.inputField}
align='left'
disabled={disabled}
/>
{children}
@@ -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,
+25 -13
View File
@@ -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', () => {
+4 -156
View File
@@ -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 '';
+2 -2
View File
@@ -3,6 +3,6 @@ export function isMacOS() {
return userAgent.includes('macintosh') || userAgent.includes('mac os');
}
export const deviceAlt = isMacOS() ? '' : 'Alt';
export const deviceAlt = isMacOS() ? 'Option' : 'Alt';
export const deviceMod = isMacOS() ? '' : 'Ctrl';
export const deviceMod = isMacOS() ? 'Cmd' : 'Ctrl';
+1
View File
@@ -9,4 +9,5 @@ export const startsWithHttp = /^http:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isASCIIorEmpty = /^$|^[ -~]+$/; //https://catonmat.net/my-favorite-regex
export const isNotEmpty = /\S/;
@@ -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;
};
+35 -6
View File
@@ -1,10 +1,10 @@
import { useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useLocalStorage } from '@mantine/hooks';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import Color from 'color';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeRundown, OntimeRundownEntry } from 'ontime-types';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useLocalStorage } from '../../common/hooks/useLocalStorage';
import { getAccessibleColour } from '../../common/utils/styleUtils';
import BlockRow from './cuesheet-table-elements/BlockRow';
@@ -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];
}
/**
+1 -1
View File
@@ -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
View File
@@ -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 */
+7 -3
View File
@@ -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: {
+2 -2
View File
@@ -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",
+3 -4
View File
@@ -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",
+28 -5
View File
@@ -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 });
+2 -1
View File
@@ -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`);
}
+2
View File
@@ -17,6 +17,7 @@ import {
resolveExternalsDirectory,
resolveStylesDirectory,
resolvedPath,
clearUploadfolder,
} from './setup/index.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
@@ -143,6 +144,7 @@ const checkStart = (currentState: OntimeStartOrder) => {
export const initAssets = async () => {
checkStart(OntimeStartOrder.InitAssets);
await dbLoadingProcess;
await clearUploadfolder();
populateStyles();
populateDemo();
};
+1 -1
View File
@@ -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 -1
View File
@@ -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;
}
+3 -3
View File
@@ -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;
}
+11 -5
View File
@@ -2,6 +2,8 @@ import { fileURLToPath } from 'url';
import path, { dirname, join } from 'path';
import fs from 'fs';
import { rm } from 'fs/promises';
import { config } from './config.js';
import { ensureDirectory } from '../utils/fileManagement.js';
@@ -57,17 +59,13 @@ const currentDir = dirname(__dirname);
export const srcDirectory = isProduction ? currentDir : path.join(currentDir, '../');
// resolve path to external
const productionPath = path.join(srcDirectory, '../../resources/extraResources/client');
const productionPath = path.join(srcDirectory, 'client/');
const devPath = path.join(srcDirectory, '../../client/build/');
const dockerPath = path.join(srcDirectory, 'client/');
export const resolvedPath = (): string => {
if (isTest) {
return devPath;
}
if (isDocker) {
return dockerPath;
}
if (isProduction) {
return productionPath;
}
@@ -144,3 +142,11 @@ export const resolveCrashReportDirectory = getAppDataPath();
// path to projects
export const resolveProjectsDirectory = join(getAppDataPath(), config.projects);
export async function clearUploadfolder() {
try {
await rm(uploadsFolderPath, { recursive: true });
} catch (_) {
//we dont care that there was no folder
}
}
@@ -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();
+27 -17
View File
@@ -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);
});
});
+128 -1
View File
@@ -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);
});
});
+12 -1
View File
@@ -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);
+2 -3
View File
@@ -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`;
}
/**
+37
View File
@@ -0,0 +1,37 @@
import { Argument } from 'node-osc';
import { splitWhitespace } from 'ontime-utils';
export function stringToOSCArgs(argsString: string | undefined): Argument[] {
if (typeof argsString === 'undefined' || argsString === '') {
return new Array<Argument>();
}
const matches = splitWhitespace(argsString);
const parsedArguments: Argument[] = matches.map((argString: string) => {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
if (!Number.isNaN(argAsNum)) {
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
}
if (argString.startsWith('"') && argString.endsWith('"')) {
// NOTE: "quoted string"
return { type: 'string', value: argString.substring(1, argString.length - 1) };
}
if (argString === 'TRUE') {
// NOTE: Boolean true
return { type: 'T', value: true };
}
if (argString === 'FALSE') {
// NOTE: Boolean false
return { type: 'F', value: false };
}
// NOTE: string
return { type: 'string', value: argString };
});
return parsedArguments;
}
+4
View File
@@ -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;
+21 -95
View File
@@ -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
View File
@@ -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;
};
+8 -4
View File
@@ -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,
+1 -1
View File
@@ -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'
});
});
+10 -4
View File
@@ -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