Compare commits

..

24 Commits

Author SHA1 Message Date
Carlos Valente 8ac5c0a9a2 Revert "Timeuntil in UI (#1461)"
This reverts commit 221ff7b3af.
2025-02-12 22:19:18 +01:00
Carlos Valente 649f0af2cf refactor: reword error 2025-02-12 22:19:18 +01:00
Carlos Valente fd9b944ecf bump version to 3.11.0 2025-02-12 22:19:18 +01:00
Alex Christoffer Rasmussen 0b36608134 Catch xlsx error (#1487)
* catch potential xlsx errors

* clear worksheet errors when starting new connections
2025-02-12 13:38:18 +01:00
Carlos Valente bac11adeb1 feat: add autocomplete to osc arguments 2025-02-10 20:01:36 +01:00
Carlos Valente 31799f8fab fix: project created without extension 2025-02-10 07:39:05 +01:00
Carlos Valente af177060ff refactor: add next to source fields 2025-02-06 21:20:25 +01:00
Carlos Valente d26a3cd393 refactor: change order of operations 2025-02-06 21:20:25 +01:00
Carlos Valente 38a3165a79 refactor: larger settings panels 2025-02-04 22:35:17 +01:00
Carlos Valente 1715c19747 refactor: clarify empty text 2025-02-04 22:35:17 +01:00
Carlos Valente 6be6e5fd8d refactor: handle overflow in menulist 2025-02-04 22:35:17 +01:00
Carlos Valente 44b0cfd928 refactor: allow locking cuesheet and operator 2025-02-04 22:35:00 +01:00
Carlos Valente 3d50b8ce49 refactor: simplify navigation lock 2025-02-04 22:35:00 +01:00
Carlos Valente c3d66f5bd1 fix: maintain locked and token params 2025-02-01 21:34:29 +01:00
Carlos Valente d08787a6ba refactor: allow presets for op and cuesheet 2025-02-01 21:34:29 +01:00
Carlos Valente f0e6231338 refactor: simplify url preset logic 2025-02-01 21:34:29 +01:00
Carlos Valente 430bfb38f2 chore: test is typescript 2025-02-01 21:34:29 +01:00
Carlos Valente 60354f8ea3 fix: prevent non alphanumeric in pincode 2025-02-01 11:38:58 +01:00
Carlos Valente bc12531729 refactor: show multiline text in custom fields 2025-02-01 11:38:58 +01:00
Carlos Valente 0b8a694e4f refactor: allow overriding operator styles 2025-02-01 11:38:58 +01:00
Carlos Valente 73ada99596 fix: update time in block (#1474)
* fix: update time in block

---------

Co-authored-by: arc-alex <ac@omnivox.dk>
2025-01-31 17:20:02 +01:00
Alex Christoffer Rasmussen f509655d31 update types/node and osc-min (#1475) 2025-01-30 22:25:25 +01:00
Carlos Valente fb1bbd6d5c refactor: fix roll next day 2025-01-27 19:24:11 +01:00
Alex Christoffer Rasmussen 221ff7b3af Timeuntil in UI (#1461)
* cherry pick EventBlockChip

* pull data down throug components

* add comment

* cleanup css

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

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-27 16:39:32 +01:00
46 changed files with 897 additions and 495 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.11.0-beta.1",
"version": "3.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.11.0-beta.1",
"version": "3.11.0",
"private": true,
"type": "module",
"dependencies": {
+4 -2
View File
@@ -44,6 +44,8 @@ const SPublic = withPreset(withData(Public));
const SLowerThird = withPreset(withData(Lower));
const SStudio = withPreset(withData(StudioClock));
const STimeline = withPreset(withData(Timeline));
const PCuesheet = withPreset(Cuesheet);
const POperator = withPreset(Operator);
const EditorFeatureWrapper = React.lazy(() => import('./features/EditorFeatureWrapper'));
const RundownPanel = React.lazy(() => import('./features/rundown/RundownExport'));
@@ -155,8 +157,8 @@ export default function AppRouter() {
{/*/!* Protected Routes *!/*/}
<Route path='/editor' element={<Editor />} />
<Route path='/cuesheet' element={<Cuesheet />} />
<Route path='/op' element={<Operator />} />
<Route path='/cuesheet' element={<PCuesheet />} />
<Route path='/op' element={<POperator />} />
{/*/!* Protected Routes - Elements *!/*/}
<Route
@@ -2,6 +2,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -15,7 +16,7 @@ export default function FloatingNavigation(props: FloatingNavigationProps) {
const isButtonShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isButtonShown ? style.hidden : ''}`}>
<div className={cx([style.fadeable, style.buttonContainer, !isButtonShown && style.hidden])}>
<button
onClick={toggleMenu}
aria-label='toggle menu'
@@ -7,24 +7,38 @@ $icon-color: $ui-white;
$button-bg: $gray-1050;
$button-size: 3rem;
.fadeable {
opacity: 1;
transition-property: opacity;
transition-duration: 0.3s;
&.hidden {
opacity: 0;
}
}
.lockIcon {
font-size: 1.5rem;
color: $active-indicator;
padding: 0.5em;
position: fixed;
left: 0;
top: 0;
z-index: 12;
}
.buttonContainer {
display: flex;
flex-direction: column;
row-gap: 1rem;
padding: 0.5em;
transition-property: opacity;
transition-duration: 0.3s;
opacity: 1;
position: fixed;
left: 0;
top: 0;
z-index: 12;
&.hidden {
opacity: 0;
}
}
.button {
@@ -38,16 +52,6 @@ $button-size: 3rem;
border-radius: 3px;
}
.lockIcon {
font-size: 1.5rem;
display: grid;
place-content: center;
border-radius: 3px;
background-color: transparent;
color: $active-indicator;
cursor: default;
}
.navButton {
@extend .button;
z-index: 3;
@@ -1,6 +1,7 @@
import { IoLockClosedOutline } from '@react-icons/all-files/io5/IoLockClosedOutline';
import { useFadeOutOnInactivity } from '../../../common/hooks/useFadeOutOnInactivity';
import { cx } from '../../utils/styleUtils';
import style from './NavigationMenu.module.scss';
@@ -8,8 +9,8 @@ export default function ViewLockedIcon() {
const isLockIconShown = useFadeOutOnInactivity();
return (
<div className={`${style.buttonContainer} ${!isLockIconShown ? style.hidden : ''}`}>
<IoLockClosedOutline className={style.lockIcon} />
<div className={cx([style.fadeable, style.lockIcon, !isLockIconShown && style.hidden])}>
<IoLockClosedOutline />
</div>
);
}
@@ -1,23 +1,20 @@
import { memo, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { memo } from 'react';
import { useDisclosure } from '@chakra-ui/react';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
import FloatingNavigation from './FloatingNavigation';
import NavigationMenu from './NavigationMenu';
import useViewEditor from './useViewEditor';
import ViewLockedIcon from './ViewLockedIcon';
function ViewNavigationMenu() {
const [searchParams, setSearchParams] = useSearchParams();
interface ViewNavigationMenuProps {
isLockable?: boolean;
}
function ViewNavigationMenu(props: ViewNavigationMenuProps) {
const { isLockable } = props;
const { isOpen: isMenuOpen, onOpen: onMenuOpen, onClose: onMenuClose } = useDisclosure();
const isViewLocked = isStringBoolean(searchParams.get('locked'));
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable });
const toggleMenu = () => (isMenuOpen ? onMenuClose() : onMenuOpen());
@@ -0,0 +1,26 @@
import { useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
interface EditorVisibilityOptions {
isLockable?: boolean;
}
export default function useViewEditor({ isLockable }: EditorVisibilityOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const isViewLocked = useMemo(() => {
if (!isLockable) {
return false;
}
return isStringBoolean(searchParams.get('locked'));
}, [isLockable, searchParams]);
return { showEditFormDrawer, isViewLocked };
}
@@ -119,7 +119,7 @@ function MultiOption(props: EditFormMultiOptionProps) {
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
</MenuButton>
<MenuList>
<MenuList overflow='auto' maxHeight='200px'>
<MenuOptionGroup
type='checkbox'
value={paramState}
@@ -1,103 +0,0 @@
import { resolvePath } from 'react-router-dom';
import { generateUrlFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('A preset fails if incorrect', () => {
const testsToFail = [
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
];
testsToFail.forEach((t) =>
it(`${t}`, () => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
}),
);
});
describe('generateUrlFromPreset and getRouteFromPreset function', () => {
test('generate the expected url from an alias', () => {
const testData = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(generateUrlFromPreset(testData[0])).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL is just the alias', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the alias
const location = resolvePath(presets[0].alias);
const expected = [
{
url: '/timer?user=guest&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, null)).toStrictEqual(expected[0].url);
});
test('generate the url to redirect to when the current URL the same url but with a change of params', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
// update current alias with extra param
presets[0].pathAndParams += '&eventId=674';
const expected = [
{
url: '/timer?user=guest&eventId=674&alias=demopage',
},
];
expect(getRouteFromPreset(location, presets, urlSearchParams)).toStrictEqual(expected[0].url);
});
test('generate no url to redirect to when the current URL the same url', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
// let current location be the actual url with alias attached to it
const location = resolvePath(presets[0].pathAndParams);
const urlSearchParams = new URLSearchParams(location.search);
urlSearchParams.append('alias', presets[0].alias); //
expect(getRouteFromPreset(location, presets, urlSearchParams)).toBeNull();
});
});
@@ -0,0 +1,107 @@
import { resolvePath } from 'react-router-dom';
import { arePathsEquivalent, generatePathFromPreset, getRouteFromPreset, validateUrlPresetPath } from '../urlPresets';
describe('validateUrlPresetPaths()', () => {
test.each([
// no empty
'',
// no https, http or www
'https://www.test.com',
'http://www.test.com',
'www.test.com',
// no hostname
'localhost/test',
'127.0.0.1/test',
'0.0.0.0/test',
// no editor
'editor',
'editor?test',
])('flags known edge cases: %s', (t) => {
expect(validateUrlPresetPath(t).isValid).toBeFalsy();
});
});
describe('getRouteFromPreset()', () => {
const presets = [
{
enabled: true,
alias: 'demopage',
pathAndParams: '/timer?user=guest',
},
];
it('checks if the current location matches an enabled preset', () => {
// we make the current location be the alias
const location = resolvePath('demopage');
expect(getRouteFromPreset(location, presets)).toStrictEqual('timer?user=guest&alias=demopage');
});
it('returns null if the current location is the exact match of an unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('returns a new destination if the current location is an out-of-date unwrapped alias', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=admin&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual('timer?user=guest&alias=demopage');
});
it('checks if the current location contains an unwrapped preset', () => {
// we make the current location be the alias
const location = resolvePath('/timer?user=guest&alias=demopage');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
it('ignores a location that has no presets', () => {
// we make the current location be the alias
const location = resolvePath('/unknown');
expect(getRouteFromPreset(location, presets)).toEqual(null);
});
describe('handle url sharing edge cases', () => {
it('finds the correct preset when the url contains extra arguments', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)?.startsWith('timer?user=guest&alias=demopage')).toBeTruthy()
})
it('appends the feature params to the alias', () => {
const location = resolvePath('/demopage?locked=true&token=123');
expect(getRouteFromPreset(location, presets)).toBe('timer?user=guest&alias=demopage&locked=true&token=123')
})
});
});
describe('generatePathFromPreset()', () => {
test.each([
['timer?user=guest', 'demopage', 'timer?user=guest&alias=demopage'],
['timer?user=admin', 'demopage', 'timer?user=admin&alias=demopage'],
])('generates a path from a preset: %s', (path, alias, expected) => {
expect(generatePathFromPreset(path, alias, null, null)).toEqual(expected);
});
test('appends the feature params to the alias', () => {
expect(generatePathFromPreset('timer?user=guest', 'demopage', 'true', '123')).toBe('timer?user=guest&alias=demopage&locked=true&token=123');
});
});
describe('arePathsEquivalent()', () => {
it("checks whether the paths match", () => {
expect(arePathsEquivalent('demopage', 'timer')).toBeFalsy();
expect(arePathsEquivalent('timer', 'timer')).toBeTruthy();
expect(arePathsEquivalent('timer?user=guest', 'timer?user=guest')).toBeTruthy();
})
it("checks whether the params match", () => {
expect(arePathsEquivalent('timer?test=a', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a', 'timer?test=a')).toBeTruthy();
})
it("considers edge cases for the url sharing feature", () => {
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=b')).toBeFalsy();
expect(arePathsEquivalent('timer?test=a&locked=true=token=123', 'timer?test=a')).toBeTruthy();
})
});
+79 -36
View File
@@ -1,13 +1,11 @@
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Path, resolvePath } from 'react-router-dom';
import { URLPreset } from 'ontime-types';
/**
* Validates a preset against defined parameters
* @param {string} preset
* @returns {{message: string, isValid: boolean}}
* Used in the context of form validation
*/
export const validateUrlPresetPath = (preset: string): { message: string; isValid: boolean } => {
export function validateUrlPresetPath(preset: string): { message: string; isValid: boolean } {
if (preset === '' || preset == null) {
return { isValid: false, message: 'Path cannot be empty' };
}
@@ -26,7 +24,7 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
}
return { isValid: true, message: 'ok' };
};
}
/**
* Utility removes trailing slash from a string
@@ -36,48 +34,93 @@ function removeTrailingSlash(text: string): string {
}
/**
* Gets the URL to send a preset to
* @param location
* @param data
* @param searchParams
* Checks whether the current location corresponds to a preset and returns the new path if necessary
*/
export const getRouteFromPreset = (location: Location, data: URLPreset[], searchParams: URLSearchParams) => {
export function getRouteFromPreset(location: Path, urlPresets: URLPreset[]): string | null {
// current url is the pathname without the leading slash
const currentURL = location.pathname.substring(1);
const searchParams = new URLSearchParams(location.search);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
// check if we have token or locked in the search params
const locked = searchParams.get('locked');
const token = searchParams.get('token');
// we need to check if the whole url is an alias
const foundPreset = urlPresets.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
// if so, we can redirect to the preset path
return generatePathFromPreset(foundPreset.pathAndParams, foundPreset.alias, locked, token);
}
// if the current url is not an alias, we check if the alias is in the search parameters
const presetOnPage = searchParams.get('alias');
for (const d of data) {
if (presetOnPage) {
// if the alias fits the preset on this page, but the URL is different, we redirect user to the new URL
// if we have the same alias and its enabled and its not empty
if (d.alias !== '' && d.enabled && d.alias === presetOnPage) {
const newPath = resolvePath(d.pathAndParams);
const urlParams = new URLSearchParams(newPath.search);
urlParams.set('alias', d.alias);
// we confirm either the url parameters does not match or the url path doesnt
if (!isEqual(urlParams, searchParams) || newPath.pathname !== location.pathname) {
// we then redirect to the alias route, since the view listening to this alias has an outdated URL
return `${newPath.pathname}?${urlParams}`;
}
if (!presetOnPage) {
return null;
}
const currentPath = `${location.pathname}${location.search}`.substring(1);
for (const preset of urlPresets) {
// if the page has a known enabled alias, we check if we need to redirect
if (preset.alias === presetOnPage && preset.enabled) {
const newPath = generatePathFromPreset(preset.pathAndParams, preset.alias, locked, token);
if (!arePathsEquivalent(currentPath, newPath)) {
// if current path is out of date
// return new path so we can redirect
return newPath;
}
}
}
return null;
};
}
/**
* Generate URL from an preset
* @param presetData
* Handles generating a path and search parameters from a preset
*/
export const generateUrlFromPreset = (presetData: URLPreset) => {
const newPresetPath = resolvePath(presetData.pathAndParams);
const urlParams = new URLSearchParams(newPresetPath.search);
urlParams.set('alias', presetData.alias);
export function generatePathFromPreset(pathAndParams: string, alias: string, locked: string | null, token: string | null ): string {
const path = resolvePath(pathAndParams);
const searchParams = new URLSearchParams(path.search);
return `${newPresetPath.pathname}?${urlParams}`;
};
// save the alias so we have a reference to it being a preset and can update if necessary
searchParams.set('alias', alias);
// maintain params from the URL search feature
if (locked) {
searchParams.set('locked', locked);
}
if (token) {
searchParams.set('token', token);
}
// return path concatenated without the leading slash
return `${path.pathname}?${searchParams}`.substring(1);
}
/**
* Utility checks if two paths are equivalent
* Considers the edge cases for url sharing where a path may contain extra arguments from the alias
* - token
* - locked
*/
export function arePathsEquivalent(currentPath: string, newPath: string): boolean {
const currentUrl = new URL(currentPath, document.location.origin);
const newUrl = new URL(newPath, document.location.origin);
// check path
if (currentUrl.pathname !== newUrl.pathname) {
return false
}
// check search params
// if the params match, we dont need further checks
if (currentUrl.searchParams.toString() === newUrl.searchParams.toString()) {
return true
}
// if there is no match, we check the edge cases for the url sharing feature
currentUrl.searchParams.delete('token');
currentUrl.searchParams.delete('locked');
return currentUrl.searchParams.toString() === newUrl.searchParams.toString();
}
+7 -7
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import useUrlPresets from '../common/hooks-query/useUrlPresets';
import { getRouteFromPreset } from '../common/utils/urlPresets';
@@ -8,19 +8,19 @@ import { getRouteFromPreset } from '../common/utils/urlPresets';
const withPreset = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
const { data } = useUrlPresets();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
// navigate if is alias route
useEffect(() => {
if (!data) return;
const url = getRouteFromPreset(location, data, searchParams);
// navigate to this route if its not empty
if (url) {
navigate(url);
const destination = getRouteFromPreset(location, data);
// navigate to this destination if its not null
if (destination) {
navigate(destination);
}
}, [data, searchParams, navigate, location]);
}, [data, navigate, location]);
return <Component {...(props as P)} />;
};
@@ -38,7 +38,7 @@ $inner-padding: 1rem;
.section {
position: relative;
margin-top: 2rem;
max-width: 800px;
max-width: 850px;
}
.indent {
@@ -25,6 +25,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import TemplateInput from './template-input/TemplateInput';
import { isAutomation, makeFieldList } from './automationUtils';
import style from './AutomationForm.module.scss';
@@ -251,7 +252,7 @@ export default function AutomationForm(props: AutomationFormProps) {
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<no value>'
placeholder='<empty / no value>'
autoComplete='off'
/>
</label>
@@ -357,7 +358,7 @@ export default function AutomationForm(props: AutomationFormProps) {
</label>
<label>
Parameters
<Input
<TemplateInput
{...register(`outputs.${index}.args`)}
variant='ontime-filled'
size='sm'
@@ -27,12 +27,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} />
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
</Panel.Section>
</>
);
@@ -36,6 +36,12 @@ const staticSelectProperties = [
{ value: 'eventNow.colour', label: 'Colour' },
];
const staticNextSelectProperties = [
{ value: 'eventNow.id', label: 'Next ID' },
{ value: 'eventNow.title', label: 'Next Title' },
{ value: 'eventNow.cue', label: 'Next Cue' },
];
type SelectableField = {
value: string; // string encodes path in runtime state object
label: string;
@@ -48,6 +54,11 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
value: `eventNow.custom.${key}`,
label: `Custom: ${label}`,
})),
...staticNextSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({
value: `eventNext.custom.${key}`,
label: `Next custom: ${label}`,
})),
];
}
@@ -0,0 +1,29 @@
.wrapper {
position: relative;
}
.suggestions {
background: $gray-1250;
color: $ui-white;
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 0;
z-index: 5;
padding-block: 0.25rem;
max-height: 200px;
overflow-y: auto;
color: $label-gray;
li {
padding: 0.25rem;
}
li:hover {
color: $ui-white;
background: $blue-700;
}
}
@@ -0,0 +1,70 @@
import { useMemo, useState } from 'react';
import { type InputProps, Input } from '@chakra-ui/react';
import { useClickOutside } from '@mantine/hooks';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { makeAutoCompleteList, matchRemaining, selectFromLastTemplate } from './templateInput.utils';
import style from './TemplateInput.module.scss';
interface TemplateInputProps extends InputProps {}
export default function TemplateInput(props: TemplateInputProps) {
const { value, onChange, ...rest } = props;
const { data } = useCustomFields();
const ref = useClickOutside(() => setShowSuggestions(false));
const autocompleteList = useMemo(() => {
return makeAutoCompleteList(data);
}, [data]);
const [inputValue, setInputValue] = useState(value || '');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const updateSuggestions = (value: string) => {
const template = selectFromLastTemplate(value);
return autocompleteList.filter((suggestion) => suggestion.startsWith(template));
};
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
if (event.target.value.endsWith('{{')) {
setShowSuggestions(true);
} else if (event.target.value === '' || event.target.value.endsWith('}}')) {
setShowSuggestions(false);
}
if (showSuggestions) {
const suggestions = updateSuggestions(event.target.value);
setSuggestions(suggestions);
}
onChange?.(event);
};
const handleSuggestion = (value: string) => {
setInputValue((prev) => {
const remaining = matchRemaining(prev as string, value);
return prev + remaining;
});
setShowSuggestions(false);
};
return (
<div className={style.wrapper} ref={ref}>
<Input {...rest} value={inputValue} onChange={handleInputChange} autoComplete='off' autoCorrect='off' />
{showSuggestions && suggestions.length > 0 && (
<ul className={style.suggestions}>
{suggestions.map((suggestion) => (
<li key={suggestion} onClick={() => handleSuggestion(suggestion)}>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,19 @@
import { matchRemaining } from '../templateInput.utils';
describe('matchRemaining()', () => {
it('should return a partial string needed for autocomplete', () => {
expect(matchRemaining('te', 'test')).toBe('st');
expect(matchRemaining('{{{hum', '{{human}}')).toBe('an}}');
expect(matchRemaining('send {', '{{human}}')).toBe('{human}}');
// we should be able to match the following
// however, the current implementation only needs to deal with strings that start with {{
// expect(matchRemaining('{', '{{human}}')).toBe('{human}}');
// expect(matchRemaining('{{', '{{human}}')).toBe('human}}');
});
it('should return an empty string if there are no matches or if it is complete', () => {
expect(matchRemaining('test', 'another')).toBe('');
expect(matchRemaining('test', 'test')).toBe('');
});
});
@@ -0,0 +1,105 @@
import { CustomFields } from 'ontime-types';
const humanConstants = [
'{{human.clock}}',
'{{human.duration}}',
'{{human.expectedEnd}}',
'{{human.runningTimer}}',
'{{human.elapsedTime}}',
'{{human.startedAt}}',
];
const staticAutocompleteOptions = [
'{{clock}}',
'{{timer.addedTime}}',
'{{timer.current}}',
'{{timer.duration}}',
'{{timer.elapsed}}',
'{{timer.expectedFinish}}',
'{{timer.finishedAt}}',
'{{timer.secondaryTimer}}',
'{{timer.startedAt}}',
'{{runtime.selectedEventIndex}}',
'{{runtime.numEvents}}',
'{{runtime.offset}}',
'{{runtime.plannedStart}}',
'{{runtime.plannedEnd}}',
'{{runtime.actualStart}}',
'{{runtime.expectedEnd}}',
'{{currentBlock.block}}',
'{{currentBlock.startedAt}}',
];
const eventStaticPropertiesNow = [
'{{eventNow.id}}',
'{{eventNow.cue}}',
'{{eventNow.title}}',
'{{eventNow.note}}',
'{{eventNow.timeStart}}',
'{{eventNow.timeEnd}}',
'{{eventNow.duration}}',
'{{eventNow.isPublic}}',
'{{eventNow.colour}}',
'{{eventNow.delay}}',
];
const eventStaticPropertiesNext = [
'{{eventNext.id}}',
'{{eventNext.cue}}',
'{{eventNext.title}}',
'{{eventNext.note}}',
'{{eventNext.timeStart}}',
'{{eventNext.timeEnd}}',
'{{eventNext.duration}}',
'{{eventNext.isPublic}}',
'{{eventNext.colour}}',
'{{eventNext.delay}}',
];
/**
* Creates a it of possible autocomplete suggestions
* Based on RuntimeState
* Appends the human readable variants to it
* It is manually kept in sync with the automation parseTemplate functions
*/
export function makeAutoCompleteList(customFields: CustomFields): string[] {
return [
...humanConstants,
...staticAutocompleteOptions,
...eventStaticPropertiesNow,
...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`),
...eventStaticPropertiesNext,
...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`),
];
}
/**
* Returns the partial string b needed to autocomplete string a
* @example matchRemaining('te', 'test') -> 'st'
* @example matchRemaining('{{', '{{human}}') -> 'man}}'
*/
export function matchRemaining(a: string, b: string) {
if (a === b) {
return '';
}
for (let i = 0; i < b.length; i++) {
const searchString = b.substring(0, i + 1);
if (a.endsWith(searchString)) {
return b.substring(i + 1);
}
}
return '';
}
/**
* Selects the last starting template in a string
*/
export function selectFromLastTemplate(text: string) {
const lastBraceIndex = text.lastIndexOf('{{');
if (lastBraceIndex !== -1) {
return text.slice(lastBraceIndex);
}
return '';
}
@@ -8,6 +8,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput';
@@ -25,6 +26,7 @@ export default function GeneralPanelForm() {
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<Settings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
@@ -94,7 +96,11 @@ export default function GeneralPanelForm() {
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
@@ -104,6 +110,7 @@ export default function GeneralPanelForm() {
variant='ontime-filled'
maxLength={5}
width='75px'
isDisabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
@@ -4,6 +4,8 @@ import { IconButton, Input, InputGroup, InputRightElement } from '@chakra-ui/rea
import { IoEyeOutline } from '@react-icons/all-files/io5/IoEyeOutline';
import { Settings } from 'ontime-types';
import { isAlphanumeric } from '../../../../common/utils/regex';
interface GeneralPinInputProps {
register: UseFormRegister<Settings>;
formName: keyof Settings;
@@ -13,14 +15,18 @@ interface GeneralPinInputProps {
export default function GeneralPinInput(props: PropsWithChildren<GeneralPinInputProps>) {
const { register, formName, isDisabled } = props;
const [isVisible, setVisible] = useState(false);
return (
<InputGroup size='sm' width='100px'>
<Input
variant='ontime-filled'
type={isVisible ? 'text' : 'password'}
maxLength={4}
{...register(formName)}
{...register(formName, {
pattern: {
value: isAlphanumeric,
message: 'Only alphanumeric characters are allowed',
},
})}
placeholder='-'
isDisabled={isDisabled}
/>
@@ -76,6 +76,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const handleConnect = async () => {
if (!file) return;
if (!sheetId) return;
patchStepData({ worksheet: { available: false, error: '' } });
setLoading('connect');
const result = await connect(file, sheetId);
@@ -51,8 +51,8 @@ const staticOptions = [
label: 'Automation',
secondary: [
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{ id: 'automation__automations', label: 'Manage automations' },
{ id: 'automation__triggers', label: 'Manage triggers' },
],
},
{
+2 -2
View File
@@ -5,7 +5,7 @@ import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoClose } from '@react-icons/all-files/io5/IoClose';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import { useElectronListener } from '../../common/hooks/useElectronEvent';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import AppSettings from '../app-settings/AppSettings';
@@ -58,7 +58,7 @@ export default function Editor() {
<div className={styles.mainContainer} data-testid='event-editor'>
<WelcomePlacement />
<Finder isOpen={isFinderOpen} onClose={onFinderClose} />
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<EditorOverview>
<IconButton
aria-label='Toggle navigation'
@@ -1,28 +1,12 @@
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useDisclosure } from '@chakra-ui/react';
import FloatingNavigation from '../../common/components/navigation-menu/FloatingNavigation';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import ViewNavigationMenu from '../../common/components/navigation-menu/ViewNavigationMenu';
import ProtectRoute from '../../common/components/protect-route/ProtectRoute';
import Operator from './Operator';
export default function OperatorExport() {
const [searchParams, setSearchParams] = useSearchParams();
const { isOpen, onOpen, onClose } = useDisclosure();
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
const toggleMenu = isOpen ? onClose : onOpen;
return (
<ProtectRoute permission='operator'>
<FloatingNavigation toggleMenu={toggleMenu} toggleSettings={showEditFormDrawer} />
<ProductionNavigationMenu isMenuOpen={isOpen} onMenuClose={onClose} />
<ViewNavigationMenu isLockable />
<Operator />
</ProtectRoute>
);
@@ -30,7 +30,7 @@
&.running {
border-top: 1px solid $gray-1300;
background-color: $active-red;
background-color: var(--operator-running-bg-override, $active-red);
}
&.past {
@@ -92,12 +92,14 @@
.fields {
grid-area: fields;
font-size: 1.25rem;
font-size: var(--operator-customfield-font-size-override, 1.25rem);
font-weight: 400;
color: $ui-black;
margin: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
row-gap: 0.25em;
.field {
font-weight: 600;
@@ -112,10 +114,11 @@
}
.value {
display: inline-flex;
padding-inline: 0.25rem;
background-color: $gray-1250;
color: $ui-white;
margin-right: 1rem;
white-space: pre;
}
}
@@ -87,7 +87,7 @@ const withData = <P extends WithDataProps>(Component: ComponentType<P>) => {
return (
<>
<ViewNavigationMenu />
<ViewNavigationMenu isLockable />
<Component
{...props}
auxTimer={auxtimer1}
@@ -1,10 +1,10 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { IconButton, Modal, ModalContent, ModalOverlay, useDisclosure } from '@chakra-ui/react';
import { IoApps } from '@react-icons/all-files/io5/IoApps';
import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline';
import ProductionNavigationMenu from '../../common/components/navigation-menu/ProductionNavigationMenu';
import NavigationMenu from '../../common/components/navigation-menu/NavigationMenu';
import useViewEditor from '../../common/components/navigation-menu/useViewEditor';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
@@ -25,7 +25,7 @@ export default function CuesheetPage() {
// TODO: can we use the normalised rundown for the table?
const { data: flatRundown } = useFlatRundown();
const { data: customFields } = useCustomFields();
const [searchParams, setSearchParams] = useSearchParams();
const { showEditFormDrawer, isViewLocked } = useViewEditor({ isLockable: true });
const { isOpen: isMenuOpen, onOpen, onClose } = useDisclosure();
const { isOpen: isEventEditorOpen, onOpen: onEventEditorOpen, onClose: onEventEditorClose } = useDisclosure();
const [eventId, setEventId] = useState<string | null>(null);
@@ -34,12 +34,6 @@ export default function CuesheetPage() {
useWindowTitle('Cuesheet');
/** Handles showing the view params edit drawer */
const showEditFormDrawer = useCallback(() => {
searchParams.set('edit', 'true');
setSearchParams(searchParams);
}, [searchParams, setSearchParams]);
/**
* Handles setting the edit modal target and visibility
*/
@@ -69,7 +63,7 @@ export default function CuesheetPage() {
</ModalContent>
</Modal>
<div className={styles.tableWrapper} data-testid='cuesheet'>
<ProductionNavigationMenu isMenuOpen={isMenuOpen} onMenuClose={onClose} />
<NavigationMenu isOpen={isMenuOpen} onClose={onClose} />
<ViewParamsEditor viewOptions={cuesheetOptions} />
<CuesheetOverview>
<IconButton
@@ -78,6 +72,7 @@ export default function CuesheetPage() {
size='lg'
icon={<IoApps />}
onClick={onOpen}
isDisabled={isViewLocked}
/>
<IconButton
aria-label='Toggle settings'
@@ -85,6 +80,7 @@ export default function CuesheetPage() {
size='lg'
icon={<IoSettingsOutline />}
onClick={showEditFormDrawer}
isDisabled={isViewLocked}
/>
</CuesheetOverview>
<CuesheetProgress />
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.11.0-beta.1",
"version": "3.11.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -3
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.11.0-beta.1",
"version": "3.11.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -19,7 +19,7 @@
"lowdb": "^7.0.1",
"multer": "^1.4.5-lts.1",
"ontime-utils": "workspace:*",
"osc-min": "^2.1.1",
"osc-min": "2.1.2",
"sanitize-filename": "^1.6.3",
"steno": "^4.0.2",
"ws": "^8.18.0",
@@ -30,7 +30,6 @@
"@types/express": "^4.17.17",
"@types/multer": "^1.4.11",
"@types/node": "catalog:",
"@types/node-osc": "^6.0.3",
"@types/websocket": "^1.0.5",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "catalog:",
+1 -5
View File
@@ -20,17 +20,13 @@ class OscServer implements IAdapter {
logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`);
this.udpSocket = dgram.createSocket('udp4');
this.udpSocket.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
this.udpSocket.on('message', (buf: ArrayBuffer) => {
this.udpSocket.on('message', (buf) => {
// message should look like /ontime/{command}/{params?} {args} where
// ontime: fixed message for app
// command: command to be called
// params: used to create a nested object to patch with
// args: extra data, only used on some API entries
/**
* TODO: remove this type casting when mergend in deleration file
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
*/
const msg = fromBuffer(buf);
if (msg.oscType === 'bundle') {
//TODO: manage bundles
@@ -33,11 +33,7 @@ function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput
function emit(targetIP: string, targetPort: number, packet: OscPacketInput) {
logger.info(LogOrigin.Tx, `Sending OSC: ${targetIP}:${targetPort}`);
/**
* TODO: remove this type casting when change is merged
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
*/
const buffer = oscPacketToBuffer(packet) as unknown as Uint8Array;
const buffer = oscPacketToBuffer(packet);
udpClient.send(buffer, 0, buffer.byteLength, targetPort, targetIP, (error) => {
if (error) {
logger.warning(LogOrigin.Tx, `Failed sending OSC: ${error}`);
+2 -2
View File
@@ -49,7 +49,7 @@ export async function patchPartialProjectFile(req: Request, res: Response<Databa
*/
export async function createProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const newFileName = await projectService.createProject(req.body.filename, {
const newFileName = await projectService.createProject(req.body.filename || 'untitled', {
project: {
title: req.body?.title ?? '',
description: req.body?.description ?? '',
@@ -75,7 +75,7 @@ export async function createProjectFile(req: Request, res: Response<{ filename:
*/
export async function quickProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
try {
const filename = await projectService.createProject(req.body.project.title, req.body);
const filename = await projectService.createProject(req.body.project.title || 'untitled', req.body);
res.status(200).send({
filename,
});
+2 -2
View File
@@ -7,6 +7,7 @@ import { ensureJsonExtension } from '../../utils/fileManagement.js';
* @description Validates request for a new project.
*/
export const validateNewProject = [
body('filename').optional().isString().trim(),
body('title').optional().isString().trim(),
body('description').optional().isString().trim(),
body('publicUrl').optional().isString().trim(),
@@ -132,8 +133,7 @@ export const validateFilenameParam = [
.customSanitizer((input: string) => sanitize(input))
.withMessage('Failed to sanitize the filename')
.notEmpty()
.withMessage('Filename was empty or contained only invalid characters')
.customSanitizer((input: string) => ensureJsonExtension(input)),
.withMessage('Filename was empty or contained only invalid characters'),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
@@ -9,6 +9,7 @@ import {
appendToName,
dockerSafeRename,
ensureDirectory,
ensureJsonExtension,
generateUniqueFileName,
getFileNameFromPath,
removeFileExtension,
@@ -20,6 +21,7 @@ import { parseRundown } from '../../utils/parserFunctions.js';
import { demoDb } from '../../models/demoProject.js';
import { config } from '../../setup/config.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { initRundown } from '../rundown-service/RundownService.js';
import {
@@ -37,7 +39,6 @@ import {
moveCorruptFile,
parseJsonFile,
} from './projectServiceUtils.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
// init dependencies
init();
@@ -257,7 +258,8 @@ export async function renameProjectFile(originalFile: string, newFilename: strin
export async function createProject(filename: string, initialData: Partial<DatabaseModel>) {
const data = safeMerge(dbModel, initialData);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, filename);
const fileNameWithExtension = ensureJsonExtension(filename);
const uniqueFileName = generateUniqueFileName(publicDir.projectsDir, fileNameWithExtension);
const newFile = getPathToProject(uniqueFileName);
// change LowDB to point to new file
@@ -208,6 +208,15 @@ async function verifySheet(
});
return { worksheetOptions: spreadsheets.data.sheets.map((i) => i.properties.title) };
} catch (error) {
// attempt to catch errors caused by importing xlsx
if (
error.code === 400 &&
Array.isArray(error.errors) &&
error.errors[0].reason === 'failedPrecondition' &&
error.errors[0].message === 'This operation is not supported for this document'
) {
throw new Error('Cannot read the linked file as a Google Sheet. It may be an .xlsx file instead.');
}
const errorMessage = getErrorMessage(error);
throw new Error(`Failed to verify sheet: ${errorMessage}`);
}
@@ -1,7 +1,18 @@
import { PlayableEvent, Playback, TimerPhase } from 'ontime-types';
import { OntimeRundown, PlayableEvent, Playback, SupportedEvent, TimerPhase } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { type RuntimeState, addTime, clear, getState, load, pause, roll, start, stop } from '../runtimeState.js';
import {
type RuntimeState,
addTime,
clear,
getState,
load,
loadBlock,
pause,
roll,
start,
stop,
} from '../runtimeState.js';
import { initRundown } from '../../services/rundown-service/RundownService.js';
const mockEvent = {
@@ -328,3 +339,125 @@ describe('roll mode', () => {
});
});
});
describe('loadBlock', () => {
test('from no-block to a block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: null,
startedAt: 123,
},
eventNow: rundown[2],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[1], startedAt: null },
eventNow: rundown[2],
});
});
test('from block to a different block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[1],
startedAt: 123,
},
eventNow: rundown[4],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[3], startedAt: null },
eventNow: rundown[4],
});
});
test('from block to a no-block will clear startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Block },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[1],
startedAt: 123,
},
eventNow: rundown[0],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: null, startedAt: null },
eventNow: rundown[0],
});
});
test('from block to same block will keep startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Block },
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: rundown[0],
startedAt: 123,
},
eventNow: rundown[2],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: rundown[0], startedAt: 123 },
eventNow: rundown[2],
});
});
test('from no-block to no-block will keep startedAt', () => {
const rundown = [
{ id: '0', type: SupportedEvent.Event },
{ id: '1', type: SupportedEvent.Event },
] as OntimeRundown;
const state = {
currentBlock: {
block: null,
startedAt: 123,
},
eventNow: rundown[0],
} as RuntimeState;
loadBlock(rundown, state);
expect(state).toMatchObject({
currentBlock: { block: null, startedAt: 123 },
eventNow: rundown[0],
});
});
});
+35 -46
View File
@@ -8,6 +8,7 @@ import {
PlayableEvent,
Playback,
Runtime,
runtimeStorePlaceholder,
TimerPhase,
TimerState,
} from 'ontime-types';
@@ -32,29 +33,6 @@ import {
import { timerConfig } from '../config/config.js';
import { loadRoll, normaliseRollStart } from '../services/rollUtils.js';
const initialRuntime: Runtime = {
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
} as const;
const initialTimer: TimerState = {
addedTime: 0,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
} as const;
export type RuntimeState = {
clock: number; // realtime clock
eventNow: PlayableEvent | null;
@@ -75,16 +53,13 @@ export type RuntimeState = {
const runtimeState: RuntimeState = {
clock: clock.timeNow(),
currentBlock: {
block: null,
startedAt: null,
},
currentBlock: { ...runtimeStorePlaceholder.currentBlock },
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: { ...initialRuntime },
timer: { ...initialTimer },
runtime: { ...runtimeStorePlaceholder.runtime },
timer: { ...runtimeStorePlaceholder.timer },
_timer: {
forceFinish: null,
totalDelay: 0,
@@ -124,7 +99,7 @@ export function clear() {
runtimeState.timer.playback = Playback.Stop;
runtimeState.clock = clock.timeNow();
runtimeState.timer = { ...initialTimer };
runtimeState.timer = { ...runtimeStorePlaceholder.timer };
// when clearing, we maintain the total delay from the rundown
runtimeState._timer.forceFinish = null;
@@ -492,6 +467,7 @@ export type UpdateResult = {
export function update(): UpdateResult {
// 0. there are some things we always do
const previousClock = runtimeState.clock;
runtimeState.clock = clock.timeNow(); // we update the clock on every update call
// 1. is playback idle?
@@ -501,7 +477,8 @@ export function update(): UpdateResult {
// 2. are we waiting to roll?
if (runtimeState.timer.playback === Playback.Roll && runtimeState.timer.secondaryTimer !== null) {
return updateIfWaitingToRoll();
const hasCrossedMidnight = previousClock > runtimeState.clock;
return updateIfWaitingToRoll(hasCrossedMidnight);
}
// 3. at this point we know that we are playing an event
@@ -543,16 +520,24 @@ export function update(): UpdateResult {
return { hasTimerFinished: false, hasSecondaryTimerFinished: false };
}
function updateIfWaitingToRoll() {
function updateIfWaitingToRoll(hasCrossedMidnight: boolean) {
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (runtimeState.eventNow === null || runtimeState._timer.secondaryTarget === null) {
throw new Error('runtimeState.updateIfWaitingToRoll: invalid state received');
}
}
//account for offset
// account for offset
const offsetClock = runtimeState.clock + runtimeState.runtime.offset;
runtimeState.timer.phase = TimerPhase.Pending;
if (hasCrossedMidnight) {
// if we crossed midnight, we need to update the target
// this is the same logic from the roll function
runtimeState._timer.secondaryTarget = normaliseRollStart(runtimeState.eventNow.timeStart, offsetClock);
}
runtimeState.timer.secondaryTimer = runtimeState._timer.secondaryTarget - offsetClock;
return { hasTimerFinished: false, hasSecondaryTimerFinished: runtimeState.timer.secondaryTimer <= 0 };
}
@@ -664,6 +649,11 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
// there is something to run, load event
// update runtime
if (runtimeState.currentBlock.startedAt === null) {
runtimeState.currentBlock.startedAt = runtimeState.clock;
}
// event will finish on time
// account for event that finishes the day after
const endTime =
@@ -686,26 +676,25 @@ export function roll(rundown: OntimeRundown, offset = 0): { eventId: MaybeString
return { eventId: runtimeState.eventNow.id, didStart: true };
}
function loadBlock(rundown: OntimeRundown) {
if (runtimeState.eventNow === null) {
/**
* handle block loading, not for use outside of runtimeState
* @param rundown
*/
export function loadBlock(rundown: OntimeRundown, state = runtimeState) {
if (state.eventNow === null) {
// we need a loaded event to have a block
runtimeState.currentBlock.block = null;
runtimeState.currentBlock.startedAt = null;
state.currentBlock.block = null;
state.currentBlock.startedAt = null;
return;
}
const newCurrentBlock = getPreviousBlock(rundown, runtimeState.eventNow.id);
// test all block change posibiletys
const formNoBlockToBlock = runtimeState.currentBlock.block === null && newCurrentBlock !== null;
const formBlockToNoBlock = runtimeState.currentBlock.block !== null && newCurrentBlock === null;
const formBlockToNewBlock = runtimeState.currentBlock.block?.id !== newCurrentBlock?.id;
const newCurrentBlock = getPreviousBlock(rundown, state.eventNow.id);
// update time only if the block has changed
if (formNoBlockToBlock || formBlockToNoBlock || formBlockToNewBlock) {
runtimeState.currentBlock.startedAt = null;
if (state.currentBlock.block?.id !== newCurrentBlock?.id) {
state.currentBlock.startedAt = null;
}
// update the block anyway
runtimeState.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
state.currentBlock.block = newCurrentBlock === null ? null : { ...newCurrentBlock };
}
@@ -1,80 +0,0 @@
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: any[] = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = '';
const expected: any[] = [];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('1 space is nothing', () => {
const test = ' ';
const expected: any[] = [];
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);
});
});
-41
View File
@@ -1,41 +0,0 @@
import type { 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);
if (!matches) {
return new Array<Argument>();
}
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;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.11.0-beta.1",
"version": "3.11.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -7,15 +7,15 @@ export const runtimeStorePlaceholder: RuntimeStore = {
clock: 0,
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
current: null, // changes on every update
duration: null, // only changes if event changes
elapsed: null, // changes on every update
expectedFinish: null, // change can only be initiated by user, can roll over midnight
finishedAt: null, // can change on update or user action
phase: TimerPhase.None, // can change on update or user action
playback: Playback.Stop, // change initiated by user
secondaryTimer: null, // change on every update
startedAt: null, // change can only be initiated by user
},
onAir: false,
message: {
@@ -29,13 +29,13 @@ export const runtimeStorePlaceholder: RuntimeStore = {
external: '',
},
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
selectedEventIndex: null, // changes if rundown changes or we load a new event
numEvents: 0, // change initiated by user
offset: 0, // changes at runtime
plannedStart: 0, // only changes if event changes
plannedEnd: 0, // only changes if event changes, overflows over dayInMs
actualStart: null, // set once we start the timer
expectedEnd: null, // changes with runtime, based on offset, overflows over dayInMs
},
currentBlock: {
block: null,
+148 -59
View File
@@ -6,6 +6,9 @@ settings:
catalogs:
default:
'@types/node':
specifier: 20.17.16
version: 20.17.16
'@typescript-eslint/eslint-plugin':
specifier: 7.16.1
version: 7.16.1
@@ -15,6 +18,9 @@ catalogs:
eslint:
specifier: 8.56.0
version: 8.56.0
eslint-config-prettier:
specifier: 9.1.0
version: 9.1.0
eslint-plugin-prettier:
specifier: 5.1.3
version: 5.1.3
@@ -37,7 +43,7 @@ importers:
version: 1.49.1
'@types/node':
specifier: 'catalog:'
version: 20.14.10
version: 20.17.16
'@typescript-eslint/eslint-plugin':
specifier: 'catalog:'
version: 7.16.1(@typescript-eslint/parser@7.16.1(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3)
@@ -188,7 +194,7 @@ importers:
version: 7.16.1(eslint@8.56.0)(typescript@5.5.3)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.2.1(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.2.1(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
eslint:
specifier: 'catalog:'
version: 8.56.0
@@ -230,19 +236,19 @@ importers:
version: 5.5.3
vite:
specifier: ^5.2.11
version: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
version: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
vite-plugin-compression2:
specifier: ^1.3.3
version: 1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vite-plugin-svgr:
specifier: ^4.2.0
version: 4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vite-tsconfig-paths:
specifier: ^4.3.1
version: 4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
version: 4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
apps/electron:
devDependencies:
@@ -310,8 +316,8 @@ importers:
specifier: workspace:*
version: link:../../packages/utils
osc-min:
specifier: ^2.1.1
version: 2.1.1
specifier: 2.1.2
version: 2.1.2
sanitize-filename:
specifier: ^1.6.3
version: 1.6.3
@@ -336,10 +342,7 @@ importers:
version: 1.4.11
'@types/node':
specifier: 'catalog:'
version: 20.14.10
'@types/node-osc':
specifier: ^6.0.3
version: 6.0.3
version: 20.17.16
'@types/websocket':
specifier: ^1.0.5
version: 1.0.5
@@ -384,7 +387,7 @@ importers:
version: 5.5.3
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@20.17.16)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
packages/types:
devDependencies:
@@ -439,7 +442,7 @@ importers:
version: 5.5.3
vitest:
specifier: 'catalog:'
version: 2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
version: 2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1)
packages:
@@ -2055,12 +2058,15 @@ packages:
'@types/multer@1.4.11':
resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==}
'@types/node-osc@6.0.3':
resolution: {integrity: sha512-f0JUTDVAlk/mV9RH6jE6g/8Y7l+Mvi3f1x7xv0RG3VI1PktaXEd60mR+4plpMS9VbRovFoPsS7qaaIDhFXdaEw==}
'@types/node@20.14.10':
resolution: {integrity: sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==}
'@types/node@20.17.16':
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
'@types/node@22.10.10':
resolution: {integrity: sha512-X47y/mPNzxviAGY5TcYPtYL8JsY3kAq2n8fMmKoRCxq/c4v4pyGNCzM2R6+M5/umG4ZfHuT+sgqDYqWc9rJ6ww==}
'@types/parse-json@4.0.0':
resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==}
@@ -3977,8 +3983,8 @@ packages:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
osc-min@2.1.1:
resolution: {integrity: sha512-3Ai9vP8AAh1sCh5Zm8BUybfulv5OvqpTVa2BcUoTrLeD1Ss3cohIQpF1f18gx0CTbi9Dv4SvtdMb7y4g++7lYA==}
osc-min@2.1.2:
resolution: {integrity: sha512-dEhg7wBJ+QTHqY4iwnctkaFWzXLq+yHA2olxYfnxBw1fMy/iWJ/b7ovfXfwc/yxLXGiUHQhTxlQSHzL8ueHz6Q==}
engines: {node: '>=0.10.0'}
p-cancelable@2.1.1:
@@ -4756,6 +4762,12 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
undici-types@6.20.0:
resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
@@ -6717,13 +6729,13 @@ snapshots:
'@types/body-parser@1.19.2':
dependencies:
'@types/connect': 3.4.35
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.0.4
'@types/keyv': 3.1.4
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/responselike': 1.0.3
'@types/color-convert@2.0.0':
@@ -6738,11 +6750,11 @@ snapshots:
'@types/connect@3.4.35':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/cors@2.8.17':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/debug@4.1.12':
dependencies:
@@ -6752,7 +6764,7 @@ snapshots:
'@types/express-serve-static-core@4.17.33':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
@@ -6765,7 +6777,7 @@ snapshots:
'@types/fs-extra@9.0.13':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/http-cache-semantics@4.0.4': {}
@@ -6773,7 +6785,7 @@ snapshots:
'@types/keyv@3.1.4':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/lodash.mergewith@4.6.7':
dependencies:
@@ -6789,19 +6801,23 @@ snapshots:
dependencies:
'@types/express': 4.17.17
'@types/node-osc@6.0.3':
dependencies:
'@types/node': 20.14.10
'@types/node@20.14.10':
dependencies:
undici-types: 5.26.5
'@types/node@20.17.16':
dependencies:
undici-types: 6.19.8
'@types/node@22.10.10':
dependencies:
undici-types: 6.20.0
'@types/parse-json@4.0.0': {}
'@types/plist@3.0.5':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
xmlbuilder: 15.1.1
optional: true
@@ -6823,7 +6839,7 @@ snapshots:
'@types/responselike@1.0.3':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
'@types/scheduler@0.16.2': {}
@@ -6832,22 +6848,22 @@ snapshots:
'@types/serve-static@1.15.0':
dependencies:
'@types/mime': 3.0.1
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/verror@1.10.9':
optional: true
'@types/websocket@1.0.5':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/ws@8.5.10':
dependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
'@types/yauzl@2.10.3':
dependencies:
'@types/node': 20.14.10
'@types/node': 22.10.10
optional: true
'@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@8.56.0)(typescript@5.5.3))(eslint@8.56.0)(typescript@5.5.3)':
@@ -6974,14 +6990,14 @@ snapshots:
'@ungap/structured-clone@1.2.0': {}
'@vitejs/plugin-react@4.2.1(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))':
'@vitejs/plugin-react@4.2.1(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))':
dependencies:
'@babel/core': 7.23.6
'@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.6)
'@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.6)
'@types/babel__core': 7.20.5
react-refresh: 0.14.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- supports-color
@@ -6992,13 +7008,21 @@ snapshots:
chai: 5.1.2
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))':
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@20.17.16)(sass@1.57.1))':
dependencies:
'@vitest/spy': 2.1.6
estree-walker: 3.0.3
magic-string: 0.30.14
optionalDependencies:
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
'@vitest/mocker@2.1.6(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))':
dependencies:
'@vitest/spy': 2.1.6
estree-walker: 3.0.3
magic-string: 0.30.14
optionalDependencies:
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
'@vitest/pretty-format@2.1.6':
dependencies:
@@ -9106,7 +9130,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
osc-min@2.1.1: {}
osc-min@2.1.2: {}
p-cancelable@2.1.1: {}
@@ -9896,6 +9920,10 @@ snapshots:
undici-types@5.26.5: {}
undici-types@6.19.8: {}
undici-types@6.20.0: {}
universalify@0.1.2: {}
universalify@0.2.0:
@@ -9968,13 +9996,13 @@ snapshots:
extsprintf: 1.4.1
optional: true
vite-node@2.1.6(@types/node@20.14.10)(sass@1.57.1):
vite-node@2.1.6(@types/node@20.17.16)(sass@1.57.1):
dependencies:
cac: 6.7.14
debug: 4.3.7
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
transitivePeerDependencies:
- '@types/node'
- less
@@ -9985,50 +10013,77 @@ snapshots:
- supports-color
- terser
vite-plugin-compression2@1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-node@2.1.6(@types/node@22.10.10)(sass@1.57.1):
dependencies:
cac: 6.7.14
debug: 4.3.7
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- stylus
- sugarss
- supports-color
- terser
vite-plugin-compression2@1.3.3(rollup@4.17.2)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
tar-mini: 0.2.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- rollup
vite-plugin-svgr@4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-plugin-svgr@4.2.0(rollup@4.17.2)(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
'@svgr/core': 8.1.0(typescript@5.5.3)
'@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.5.3))
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- rollup
- supports-color
- typescript
vite-tsconfig-paths@4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1)):
vite-tsconfig-paths@4.3.1(typescript@5.5.3)(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1)):
dependencies:
debug: 4.3.7
globrex: 0.1.2
tsconfck: 3.0.2(typescript@5.5.3)
optionalDependencies:
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@5.2.11(@types/node@20.14.10)(sass@1.57.1):
vite@5.2.11(@types/node@20.17.16)(sass@1.57.1):
dependencies:
esbuild: 0.20.2
postcss: 8.4.38
rollup: 4.17.2
optionalDependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
fsevents: 2.3.3
sass: 1.57.1
vitest@2.1.6(@types/node@20.14.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
vite@5.2.11(@types/node@22.10.10)(sass@1.57.1):
dependencies:
esbuild: 0.20.2
postcss: 8.4.38
rollup: 4.17.2
optionalDependencies:
'@types/node': 22.10.10
fsevents: 2.3.3
sass: 1.57.1
vitest@2.1.6(@types/node@20.17.16)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
dependencies:
'@vitest/expect': 2.1.6
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@20.14.10)(sass@1.57.1))
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@20.17.16)(sass@1.57.1))
'@vitest/pretty-format': 2.1.6
'@vitest/runner': 2.1.6
'@vitest/snapshot': 2.1.6
@@ -10044,11 +10099,47 @@ snapshots:
tinyexec: 0.3.1
tinypool: 1.0.2
tinyrainbow: 1.2.0
vite: 5.2.11(@types/node@20.14.10)(sass@1.57.1)
vite-node: 2.1.6(@types/node@20.14.10)(sass@1.57.1)
vite: 5.2.11(@types/node@20.17.16)(sass@1.57.1)
vite-node: 2.1.6(@types/node@20.17.16)(sass@1.57.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.14.10
'@types/node': 20.17.16
happy-dom: 16.7.2
jsdom: 21.1.0
transitivePeerDependencies:
- less
- lightningcss
- msw
- sass
- stylus
- sugarss
- supports-color
- terser
vitest@2.1.6(@types/node@22.10.10)(happy-dom@16.7.2)(jsdom@21.1.0)(sass@1.57.1):
dependencies:
'@vitest/expect': 2.1.6
'@vitest/mocker': 2.1.6(vite@5.2.11(@types/node@22.10.10)(sass@1.57.1))
'@vitest/pretty-format': 2.1.6
'@vitest/runner': 2.1.6
'@vitest/snapshot': 2.1.6
'@vitest/spy': 2.1.6
'@vitest/utils': 2.1.6
chai: 5.1.2
debug: 4.3.7
expect-type: 1.1.0
magic-string: 0.30.14
pathe: 1.1.2
std-env: 3.8.0
tinybench: 2.9.0
tinyexec: 0.3.1
tinypool: 1.0.2
tinyrainbow: 1.2.0
vite: 5.2.11(@types/node@22.10.10)(sass@1.57.1)
vite-node: 2.1.6(@types/node@22.10.10)(sass@1.57.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.10
happy-dom: 16.7.2
jsdom: 21.1.0
transitivePeerDependencies:
@@ -10145,8 +10236,6 @@ snapshots:
wrappy@1.0.2: {}
ws@8.13.0: {}
ws@8.18.0: {}
xlsx@0.18.5:
+1 -1
View File
@@ -6,7 +6,7 @@ catalog:
typescript: 5.5.3
"@typescript-eslint/eslint-plugin": 7.16.1
"@typescript-eslint/parser": 7.16.1
"@types/node": 20.14.10
"@types/node": 20.17.16
prettier: 3.3.1
eslint: 8.56.0
eslint-config-prettier: 9.1.0