Compare commits

..

15 Commits

Author SHA1 Message Date
Carlos Valente a40fa0dbf8 chore: version bump 2024-05-07 21:00:42 +02:00
arc-alex e92f59ac55 await and display errors 2024-05-07 15:49:07 +02:00
Carlos Valente 077c67e912 chore: sign macos dist 2024-05-07 12:42:47 +02:00
Alex Christoffer Rasmussen 9644cbde92 fix: demo page, update docs link and fix data destructuring (#932) 2024-05-02 12:17:08 +02:00
Charalambos Makridakis 8b0e03e980 #914 Refactor: Luxon-Depenency, formatFromMillis() (#924) 2024-04-29 23:20:12 +02:00
Carlos Valente 0966659596 chore: version bump 2024-04-29 22:44:24 +02:00
Carlos Valente 1123e29499 fix: explicit rundown order in tests 2024-04-29 22:41:02 +02:00
Carlos Valente 3d2248cceb style: remove unnecessary padding 2024-04-29 22:41:02 +02:00
Carlos Valente 043ba38916 chore: update typeface 2024-04-29 22:41:02 +02:00
Carlos Valente 5b6d4c5b28 style: toggle settings button 2024-04-29 22:41:02 +02:00
Carlos Valente 07e8bedff4 style: text formatting in menu 2024-04-29 22:41:02 +02:00
Carlos Valente 6e61a7ecf9 refactor: runtime side effects 2024-04-29 22:40:39 +02:00
Alex Christoffer Rasmussen accdf2c396 Fix: custom fields with undefined colour is not accepted by parseCustomFields (#925) 2024-04-29 22:33:58 +02:00
Alex Christoffer Rasmussen 5e39c9b51f Feat: export custom fileds to sheets (#926)
* export custom keys to sheets

* use `:` in place of `-`
2024-04-29 15:15:18 +02:00
Carlos Valente b52fb0f9ee chore: update automation scripts 2024-04-28 22:11:42 +02:00
30 changed files with 751 additions and 490 deletions
@@ -1,9 +1,8 @@
name: Ontime build v3
name: Ontime build
on:
# Only trigger manually for now for testing
# push:
# tags: [ "*" ]
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
@@ -31,7 +30,14 @@ jobs:
run: pnpm build
- name: Electron - Build app
env:
APPLE_ID: ${{ secrets.APPLEID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLEIDPASS }}
APPLE_TEAM_ID: ${{ secrets.TEAMID }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
run: pnpm dist-mac
timeout-minutes: 60
- name: Release
uses: softprops/action-gh-release@v1
@@ -1,4 +1,4 @@
name: Docker Image CI Ontime V2
name: Docker Image CI Ontime
on:
push:
@@ -13,14 +13,14 @@ jobs:
CI: ''
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Setup Node.js environment
uses: actions/setup-node@v3.6.0
uses: actions/setup-node@v4
with:
version: 16.16.0
node-version: 18.18.2
- name: Setup pnpm
uses: pnpm/action-setup@v2
-108
View File
@@ -1,108 +0,0 @@
name: Ontime build v2
on:
push:
tags: [ "*" ]
workflow_dispatch:
jobs:
build_macos:
runs-on: macOS-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
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 build
- name: Electron - Build app
run: pnpm dist-mac
- name: Release
uses: softprops/action-gh-release@v1
with:
files: |
./apps/electron/dist/ontime-macOS-x64.dmg
./apps/electron/dist/ontime-macOS-arm64.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
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 build
- name: Electron - Build app
run: pnpm dist-win
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-win64.exe'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_ubuntu:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
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 build
- name: Electron - Build app
run: pnpm dist-linux
- name: Release
uses: softprops/action-gh-release@v1
with:
files: './apps/electron/dist/ontime-linux.AppImage'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,9 +1,8 @@
name: Ontime test v3
name: Ontime test
on:
# Only trigger manually for now for testing
# pull_request:
# branches: '*'
pull_request:
branches: '*'
workflow_dispatch:
jobs:
-106
View File
@@ -1,106 +0,0 @@
name: Ontime test v2
on:
pull_request:
branches: '*'
workflow_dispatch:
jobs:
unit-test:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CI: ''
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
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
# Run code quality per package
- name: React - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/client
- name: Server - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./apps/server
- name: Utils - Run linter + TypeScript checks
if: always()
run: pnpm lint && tsc --noEmit
working-directory: ./packages/utils
- name: Types - Run linter
if: always()
run: pnpm lint
working-directory: ./packages/types
# We choose to run tests separately
- name: React - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/client
- name: Server - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./apps/server
- name: Utils - Run unit tests
if: always()
run: pnpm test:pipeline
working-directory: ./packages/utils
e2e-test:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
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 client
run: pnpm build:local
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: pnpm e2e
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- uses: actions/upload-artifact@v3
with:
name: automated-screenshots
path: automated-screenshots/
retention-days: 14
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.0.0-beta.2",
"version": "3.0.0",
"private": true,
"type": "module",
"dependencies": {
@@ -10,6 +10,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.10.6",
"@emotion/styled": "^11.10.6",
"@fontsource/open-sans": "^5.0.28",
"@mantine/hooks": "^7.6.2",
"@react-icons/all-files": "^4.1.0",
"@sentry/react": "^7.92.0",
@@ -28,7 +29,6 @@
"react-hook-form": "^7.49.2",
"react-qr-code": "^2.0.12",
"react-router-dom": "^6.3.0",
"typeface-open-sans": "^1.1.13",
"web-vitals": "^3.1.1",
"zustand": "^4.5.0"
},
-4
View File
@@ -13,10 +13,6 @@ import theme from './theme/theme';
import { TranslationProvider } from './translation/TranslationProvider';
import AppRouter from './AppRouter';
// Load Open Sans typeface
// @ts-expect-error no types from font import
import('typeface-open-sans');
const preferredClientName = socketClientName.getState().name;
connectSocket(preferredClientName);
@@ -33,6 +33,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
const patchStepData = useSheetStore((state) => state.patchStepData);
const authenticationStatus = useSheetStore((state) => state.authenticationStatus);
const setAuthenticationStatus = useSheetStore((state) => state.setAuthenticationStatus);
const authenticationError = useSheetStore((state) => state.stepData.authenticate.error);
/** Check if we are authenticated */
const getAuthStatus = async () => {
@@ -44,6 +45,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
/** check if the current session has been authenticated */
useEffect(() => {
patchStepData({ authenticate: { available: false, error: '' } });
untilAuthenticated();
}, []);
@@ -150,7 +152,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
</Panel.Title>
<Panel.ListGroup>
<Panel.Description>Upload Client Secret provided by Google</Panel.Description>
<Panel.Error>{undefined}</Panel.Error>
<Panel.Error>{authenticationError}</Panel.Error>
<Input
type='file'
onChange={handleClientSecret}
@@ -25,9 +25,9 @@ export default function useGoogleSheet() {
/** whether the current session has been authenticated */
const verifyAuth = async (): Promise<{ authenticated: AuthenticationStatus; sheetId: string } | void> => {
try {
return verifyAuthenticationStatus();
} catch (_error) {
/** we do not handle errors here */
return await verifyAuthenticationStatus();
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
@@ -37,18 +37,18 @@ export default function useGoogleSheet() {
sheetId: string,
): Promise<{ verification_url: string; user_code: string } | void> => {
try {
return requestConnection(file, sheetId);
} catch (_error) {
/** we do not handle errors here */
return await requestConnection(file, sheetId);
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
/** requests the revoking of an existing authenticated session */
const revoke = async (): Promise<{ authenticated: AuthenticationStatus } | void> => {
try {
return revokeAuthentication();
} catch (_error) {
/** we do not handle errors here */
return await revokeAuthentication();
} catch (error) {
patchStepData({ authenticate: { available: false, error: maybeAxiosError(error) } });
}
};
+3 -2
View File
@@ -1,6 +1,7 @@
import { lazy, useCallback, useEffect } from 'react';
import { IconButton, useDisclosure } from '@chakra-ui/react';
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';
@@ -84,9 +85,9 @@ export default function Editor() {
/>
<IconButton
aria-label='Toggle settings'
variant='ontime-subtle-white'
variant={isSettingsOpen ? 'ontime-subtle' : 'ontime-subtle-white'}
size='lg'
icon={<IoSettingsOutline />}
icon={isSettingsOpen ? <IoClose /> : <IoSettingsOutline />}
onClick={toggleSettings}
/>
</EditorOverview>
@@ -137,7 +137,6 @@
}
.qr {
margin-left: clamp(16px, 5vw, 64px);
padding: 4px;
background-color: white;
}
+31
View File
@@ -1,5 +1,36 @@
@use './theme/ontimeStyles' as *;
/* open-sans-latin-300-normal */
@font-face {
font-family: 'Open Sans';
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;
}
/* open-sans-latin-400-normal */
@font-face {
font-family: 'Open Sans';
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;
}
/* open-sans-latin-600-normal */
@font-face {
font-family: 'Open Sans';
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;
}
$track-color: $white-1;
$thumb-color: $gray-1100;
$thumb-color-hover: $gray-900;
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+15 -8
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-prerelease",
"version": "3.0.0-beta.2",
"name": "ontime",
"version": "3.0.0",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
@@ -13,7 +13,7 @@
"main": "main.js",
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.9.1",
"electron-builder": "^24.13.3",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"prettier": "^3.0.3"
@@ -23,20 +23,27 @@
"lint": "eslint . --quiet",
"lint-staged": "eslint",
"dev:electron": "cross-env NODE_ENV=development electron .",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux",
"dist-win": "electron-builder --publish=never --x64 --win",
"dist-mac": "electron-builder --publish=never --mac",
"dist-linux": "electron-builder --publish=never --x64 --linux",
"cleanup": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"build": {
"productName": "ontime-prerelease",
"appId": "no.lightdev.ontime.prerelease",
"productName": "ontime",
"appId": "no.lightdev.ontime",
"asar": true,
"dmg": {
"artifactName": "ontime-macOS-${arch}.dmg",
"icon": "icon.icns"
},
"mac": {
"notarize": {
"teamId": "MDAU6QK6R4"
},
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "./entitlements.plist",
"entitlementsInherit": "./entitlements.plist",
"target": {
"target": "dmg",
"arch": [
+1 -1
View File
@@ -18,7 +18,7 @@ function getApplicationMenu(isMac, askToQuit, urlBase) {
{ role: 'unhide' },
{ type: 'separator' },
{
label: 'quit',
label: 'Quit',
click: () => askToQuit(),
accelerator: 'Cmd+Q',
},
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.0.0-beta.2",
"version": "3.0.0",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
+3 -3
View File
@@ -66,9 +66,9 @@ const connectSocket = () => {
switch (type) {
case 'ontime': {
// destructure known data from ontime
// see https://docs.getontime.no/api/osc-and-ws/
const { timer, playback } = payload;
updateTimerElement(playback, timer);
// see https://docs.getontime.no/api/data/runtime-data/
const { current, playback } = payload.timer;
updateTimerElement(playback, current);
break;
}
case 'ontime-timer': {
+2 -120
View File
@@ -1,27 +1,15 @@
import { OntimeEvent, Playback, RuntimeStore } from 'ontime-types';
import { OntimeEvent } from 'ontime-types';
import { deepEqual } from 'fast-equals';
import { eventStore } from '../stores/EventStore.js';
import * as runtimeState from '../stores/runtimeState.js';
import type { RuntimeState, UpdateResult } from '../stores/runtimeState.js';
import { restoreService } from './RestoreService.js';
import type { UpdateResult } from '../stores/runtimeState.js';
/**
* Service manages Ontime's main timer
* It is responsible for streaming the data to the event store
*/
export class TimerService {
private readonly _interval: NodeJS.Timer;
/** how often we update the socket */
static _updateInterval: number;
/** how often we recalculate */
static _refreshInterval: number;
/** last time we updated the socket */
static previousUpdate: number;
/** last known state */
static previousState: RuntimeState;
/** when timer will be finished */
private endCallback: NodeJS.Timer;
@@ -39,10 +27,6 @@ export class TimerService {
updateInterval: number;
onUpdateCallback: (updateResult: UpdateResult) => void;
}) {
TimerService.previousUpdate = -1;
TimerService.previousState = {} as RuntimeState;
TimerService._updateInterval = timerConfig.updateInterval;
TimerService._refreshInterval = timerConfig.refresh;
this.onUpdateCallback = timerConfig.onUpdateCallback;
@@ -51,7 +35,6 @@ export class TimerService {
}, TimerService._refreshInterval);
}
@broadcastResult
start() {
if (!runtimeState.start()) {
return false;
@@ -63,7 +46,6 @@ export class TimerService {
return true;
}
@broadcastResult
pause() {
if (!runtimeState.pause()) {
return false;
@@ -74,7 +56,6 @@ export class TimerService {
return true;
}
@broadcastResult
stop() {
if (!runtimeState.stop()) {
return false;
@@ -89,7 +70,6 @@ export class TimerService {
* Adds time to running timer by given amount
* @param {number} amount
*/
@broadcastResult
addTime(amount: number): boolean {
if (!runtimeState.addTime(amount)) {
return false;
@@ -105,7 +85,6 @@ export class TimerService {
/**
* Update the app at regular intervals
*/
@broadcastResult
update() {
const updateResult = runtimeState.update();
// pass the result to the parent
@@ -116,7 +95,6 @@ export class TimerService {
* Loads roll information into timer service
* @param {OntimeEvent[]} rundown -- list of events to run
*/
@broadcastResult
roll(rundown: OntimeEvent[]) {
runtimeState.roll(rundown);
}
@@ -126,99 +104,3 @@ export class TimerService {
clearTimeout(this.endCallback);
}
}
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// call the original method and get the state
const result = originalMethod.apply(this, args);
const state = runtimeState.getState();
// 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 < TimerService.previousUpdate ||
state.clock - TimerService.previousUpdate >= TimerService._updateInterval;
// some changes need an immediate update
const hasNewLoaded = state.eventNow?.id !== TimerService.previousState?.eventNow?.id;
const hasSkippedBack = state.clock < TimerService.previousUpdate;
const justStarted = !TimerService.previousState?.timer;
const hasChangedPlayback = TimerService.previousState.timer?.playback !== state.timer.playback;
const hasImmediateChanges = hasNewLoaded || hasSkippedBack || justStarted || hasChangedPlayback;
if (hasChangedPlayback) {
eventStore.set('onAir', state.timer.playback !== Playback.Stop);
}
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(TimerService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer);
TimerService.previousState.timer = { ...state.timer };
}
if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(TimerService.previousState?.runtime, state.runtime))) {
eventStore.set('runtime', state.runtime);
TimerService.previousState.runtime = { ...state.runtime };
}
// Update the events if they have changed
updateEventIfChanged('eventNow', state);
updateEventIfChanged('publicEventNow', state);
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (isTimeToUpdate) {
TimerService.previousUpdate = state.clock;
eventStore.set('clock', state.clock);
saveRestoreState(state);
}
// Helper function to update an event if it has changed
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: RuntimeState) {
const previous = TimerService.previousState?.[eventKey];
const now = state[eventKey];
// if there was nothing, and there is nothing, noop
if (!previous?.id && !now?.id) {
return;
}
// if load status changed, save new
if (previous?.id !== now?.id) {
storeKey(eventKey);
return;
}
// maybe the event itself has changed
if (!deepEqual(TimerService.previousState?.[eventKey], state[eventKey])) {
storeKey(eventKey);
return;
}
function storeKey(eventKey: keyof RuntimeStore) {
eventStore.set(eventKey, state[eventKey]);
TimerService.previousState[eventKey] = { ...state[eventKey] };
}
}
// Helper function to save the restore state
function saveRestoreState(state: RuntimeState) {
restoreService.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
}
return result;
};
return descriptor;
}
@@ -1,7 +1,7 @@
import { LogOrigin } from 'ontime-types';
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
import { getState } from '../../stores/runtimeState.js';
import { eventStore } from '../../stores/EventStore.js';
import { logger } from '../../classes/Logger.js';
class IntegrationService {
@@ -20,14 +20,7 @@ class IntegrationService {
}
dispatch(action: TimerLifeCycleKey) {
/**
* We currently get the state from the runtimeState store
* This solves an issue where the state is not updated until after the integrations have ran
* The workaround solves the issue with the tradeoff of
* - we do not have access to data outside runtimeState (eg: messages or auxtimers)
* - we couple the integrationService to runtimeState
*/
const state = getState();
const state = eventStore.poll();
this.integrations.forEach((integration) => {
integration.dispatch(action, state);
});
@@ -1,12 +1,16 @@
import { EndAction, LogOrigin, OntimeEvent, Playback, TimerLifeCycle } from 'ontime-types';
import { EndAction, LogOrigin, OntimeEvent, Playback, RuntimeStore, TimerLifeCycle } from 'ontime-types';
import { millisToString, validatePlayback } from 'ontime-utils';
import { TimerService } from '../TimerService.js';
import { deepEqual } from 'fast-equals';
import { logger } from '../../classes/Logger.js';
import { RestorePoint } from '../RestoreService.js';
import * as runtimeState from '../../stores/runtimeState.js';
import type { RuntimeState } from '../../stores/runtimeState.js';
import { timerConfig } from '../../config/config.js';
import { eventStore } from '../../stores/EventStore.js';
import { TimerService } from '../TimerService.js';
import { RestorePoint, restoreService } from '../RestoreService.js';
import {
findNext,
findPrevious,
@@ -16,7 +20,6 @@ import {
getPlayableEvents,
} from '../rundown-service/rundownUtils.js';
import { integrationService } from '../integration-service/IntegrationService.js';
import { timerConfig } from '../../config/config.js';
/**
* Service manages runtime status of app
@@ -26,11 +29,24 @@ class RuntimeService {
private eventTimer: TimerService | null = null;
private lastOnUpdate = -1;
/** last time we updated the socket */
static previousUpdate: number;
/** last known state */
static previousState: RuntimeState;
constructor() {
RuntimeService.previousUpdate = -1;
RuntimeService.previousState = {} as RuntimeState;
}
/** Checks result of an update and notifies integrations as needed */
@broadcastResult
checkTimerUpdate({ shouldCallRoll, hasTimerFinished }: runtimeState.UpdateResult) {
const newState = runtimeState.getState();
if (hasTimerFinished) {
integrationService.dispatch(TimerLifeCycle.onFinish);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onFinish);
});
// handle end action if there was a timer playing
// actions are added to the queue stack to ensure that the order of operations is maintained
@@ -49,10 +65,14 @@ class RuntimeService {
if (newState.clock - this.lastOnUpdate >= timerConfig.notificationRate) {
const hasRunningTimer = Boolean(newState.eventNow) && newState.timer.playback === Playback.Play;
if (hasRunningTimer) {
integrationService.dispatch(TimerLifeCycle.onUpdate);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onUpdate);
});
}
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onClock);
});
integrationService.dispatch(TimerLifeCycle.onClock);
this.lastOnUpdate = newState.clock;
}
@@ -186,6 +206,7 @@ class RuntimeService {
* @param {OntimeEvent} event
* @return {boolean} success - whether an event was loaded
*/
@broadcastResult
loadEvent(event: OntimeEvent): boolean {
if (event.skip) {
logger.warning(LogOrigin.Playback, `Refused skipped event with ID ${event.id}`);
@@ -196,10 +217,10 @@ class RuntimeService {
const success = runtimeState.load(event, timedEvents);
if (success) {
// TODO: dispatch should happen after store update
// currently store update is handled in TimerService only
integrationService.dispatch(TimerLifeCycle.onLoad);
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onLoad);
});
}
return success;
}
@@ -325,6 +346,7 @@ class RuntimeService {
/**
* Starts playback on selected event
*/
@broadcastResult
start(): boolean {
const state = runtimeState.getState();
const canStart = validatePlayback(state.timer.playback).start;
@@ -335,7 +357,9 @@ class RuntimeService {
const didStart = this.eventTimer?.start() ?? false;
logger.info(LogOrigin.Playback, `Play Mode ${state.timer.playback.toUpperCase()}`);
if (didStart) {
integrationService.dispatch(TimerLifeCycle.onStart);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStart);
});
}
return didStart;
}
@@ -367,6 +391,7 @@ class RuntimeService {
/**
* Pauses playback on selected event
*/
@broadcastResult
pause() {
const state = runtimeState.getState();
const canPause = validatePlayback(state.timer.playback).pause;
@@ -376,12 +401,15 @@ class RuntimeService {
this.eventTimer?.pause();
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onPause);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onPause);
});
}
/**
* Stops timer and unloads any events
*/
@broadcastResult
stop(): boolean {
const state = runtimeState.getState();
const canStop = validatePlayback(state.timer.playback).stop;
@@ -392,7 +420,10 @@ class RuntimeService {
if (didStop) {
const newState = state.timer.playback;
logger.info(LogOrigin.Playback, `Play Mode ${newState.toUpperCase()}`);
integrationService.dispatch(TimerLifeCycle.onStop);
process.nextTick(() => {
integrationService.dispatch(TimerLifeCycle.onStop);
});
return true;
}
return false;
@@ -401,6 +432,7 @@ class RuntimeService {
/**
* Reloads current event
*/
@broadcastResult
reload() {
const state = runtimeState.getState();
if (state.eventNow) {
@@ -411,6 +443,7 @@ class RuntimeService {
/**
* Sets playback to roll
*/
@broadcastResult
roll() {
const beforeState = runtimeState.getState();
const canRoll = validatePlayback(beforeState.timer.playback).roll;
@@ -435,6 +468,7 @@ class RuntimeService {
* @description resume playback state given a restore point
* @param restorePoint
*/
@broadcastResult
resume(restorePoint: RestorePoint) {
const { selectedEventId, playback } = restorePoint;
if (playback === Playback.Roll) {
@@ -470,3 +504,99 @@ class RuntimeService {
}
export const runtimeService = new RuntimeService();
function broadcastResult(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// call the original method and get the state
const result = originalMethod.apply(this, args);
const state = runtimeState.getState();
// 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;
// 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;
if (hasChangedPlayback) {
eventStore.set('onAir', state.timer.playback !== Playback.Stop);
}
if (hasImmediateChanges || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.timer, state.timer))) {
eventStore.set('timer', state.timer);
RuntimeService.previousState.timer = { ...state.timer };
}
if (hasChangedPlayback || (isTimeToUpdate && !deepEqual(RuntimeService.previousState?.runtime, state.runtime))) {
eventStore.set('runtime', state.runtime);
RuntimeService.previousState.runtime = { ...state.runtime };
}
// Update the events if they have changed
updateEventIfChanged('eventNow', state);
updateEventIfChanged('publicEventNow', state);
updateEventIfChanged('eventNext', state);
updateEventIfChanged('publicEventNext', state);
if (isTimeToUpdate) {
RuntimeService.previousUpdate = state.clock;
eventStore.set('clock', state.clock);
saveRestoreState(state);
}
// Helper function to update an event if it has changed
function updateEventIfChanged(eventKey: keyof RuntimeStore, state: runtimeState.RuntimeState) {
const previous = RuntimeService.previousState?.[eventKey];
const now = state[eventKey];
// if there was nothing, and there is nothing, noop
if (!previous?.id && !now?.id) {
return;
}
// if load status changed, save new
if (previous?.id !== now?.id) {
storeKey(eventKey);
return;
}
// maybe the event itself has changed
if (!deepEqual(RuntimeService.previousState?.[eventKey], state[eventKey])) {
storeKey(eventKey);
return;
}
function storeKey(eventKey: keyof RuntimeStore) {
eventStore.set(eventKey, state[eventKey]);
RuntimeService.previousState[eventKey] = { ...state[eventKey] };
}
}
// Helper function to save the restore state
function saveRestoreState(state: runtimeState.RuntimeState) {
restoreService.save({
playback: state.timer.playback,
selectedEventId: state.eventNow?.id ?? null,
startedAt: state.timer.startedAt,
addedTime: state.timer.addedTime,
pausedAt: state._timer.pausedAt,
firstStart: state.runtime.actualStart,
});
}
return result;
};
return descriptor;
}
@@ -121,6 +121,10 @@ function getCellData(key: string, event: OntimeRundownEntry) {
if (key === 'colour') {
return { userEnteredValue: { stringValue: event[key] } };
}
if (key.startsWith('custom')) {
const customKey = key.split(':')[1];
return { userEnteredValue: { stringValue: event.custom[customKey] } };
}
const dataType = typeof event[key];
if (dataType === 'number') {
+19 -12
View File
@@ -1488,24 +1488,31 @@ describe('parseExcel()', () => {
};
const result = parseExcel(testData, importMap);
const rundown = parseRundown(result);
const initialRundown = parseRundown(result);
cache.init(rundown, {});
const cachedRundown = cache.get().rundown;
cache.init(initialRundown, {});
const { rundown, order } = cache.get();
const events = Object.values(cachedRundown).filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[];
const firstId = order.at(0); // A
const secondId = order.at(1); // C
const thirdId = order.at(2); // D
const fourthId = order.at(3); // E
const fifhtId = order.at(4); // Block
const sixthId = order.at(5); // G
expect(events.at(0).timeStart).toEqual(16200000);
expect((rundown[firstId] as OntimeEvent).timeStart).toEqual(16200000);
expect(events.at(1).timeStart).toEqual(events.at(0).timeEnd);
expect(events.at(1).linkStart).toEqual(events.at(0).id);
expect((rundown[secondId] as OntimeEvent).timeStart).toEqual((rundown[firstId] as OntimeEvent).timeEnd);
expect((rundown[secondId] as OntimeEvent).linkStart).toEqual((rundown[firstId] as OntimeEvent).id);
expect(events.at(2).timeStart).toEqual(events.at(1).timeEnd);
expect(events.at(2).linkStart).toEqual(events.at(1).id);
expect((rundown[thirdId] as OntimeEvent).timeStart).toEqual((rundown[secondId] as OntimeEvent).timeEnd);
expect((rundown[thirdId] as OntimeEvent).linkStart).toEqual((rundown[secondId] as OntimeEvent).id);
expect(events.at(3).timeStart).toEqual(78300000);
expect((rundown[fourthId] as OntimeEvent).timeStart).toEqual(78300000);
expect(events.at(4).timeStart).toEqual(events.at(3).timeEnd);
expect(events.at(4).linkStart).toEqual(events.at(3).id);
expect((rundown[fifhtId] as OntimeEvent).type).toEqual(SupportedEvent.Block);
expect((rundown[sixthId] as OntimeEvent).timeStart).toEqual((rundown[fourthId] as OntimeEvent).timeEnd);
expect((rundown[sixthId] as OntimeEvent).linkStart).toEqual((rundown[fourthId] as OntimeEvent).id);
});
});
@@ -1,5 +1,5 @@
import { HttpSubscription, OscSubscription } from 'ontime-types';
import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../parserFunctions.js';
import { CustomFields, HttpSubscription, OscSubscription } from 'ontime-types';
import { sanitiseCustomFields, sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../parserFunctions.js';
describe('sanitiseOscSubscriptions()', () => {
it('returns an empty array if not an array', () => {
@@ -71,3 +71,87 @@ describe('sanitiseHttpSubscriptions()', () => {
expect(sanitationResult.length).toBe(0);
});
});
describe('sanitiseCustomFields()', () => {
it('returns an empty array if not an array', () => {
expect(sanitiseCustomFields({})).toEqual({});
});
it('returns an object of valid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(customFields);
});
it('type is forced to be string', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'another', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('colour must be a string', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 5 },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('label can not be empty', () => {
const customFields: CustomFields = {
['']: { label: '', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual({});
});
it('remove extra stuff', () => {
const customFields: CustomFields = {
// @ts-expect-error intentional bad data
test: { label: 'test', type: 'string', colour: 'red', extra: 'should be removed' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('enforece name cohesion', () => {
const customFields: CustomFields = {
test: { label: 'New Name', type: 'string', colour: 'red' },
};
const expectedCustomFields: CustomFields = {
['new name']: { label: 'New Name', type: 'string', colour: 'red' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
it('filters invalid entries', () => {
const customFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
bad: { label: '', type: 'string', colour: '' },
test3: { label: 'Test3', type: 'string', colour: '' },
};
const expectedCustomFields: CustomFields = {
test: { label: 'test', type: 'string', colour: 'red' },
test2: { label: 'test2', type: 'string', colour: 'green' },
test3: { label: 'Test3', type: 'string', colour: '' },
};
const sanitationResult = sanitiseCustomFields(customFields);
expect(sanitationResult).toStrictEqual(expectedCustomFields);
});
});
+1 -1
View File
@@ -180,7 +180,7 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
},
custom: (row: number, col: number, columnText: string) => {
customFieldIndexes[col] = columnText;
rundownMetadata[`custom-${columnText}`] = { row, col };
rundownMetadata[`custom:${columnText}`] = { row, col };
},
} as const;
+9 -5
View File
@@ -248,20 +248,24 @@ export const parseCustomFields = (data: Partial<DatabaseModel>): CustomFields =>
if (typeof data.customFields !== 'object') {
return { ...dbModel.customFields };
}
console.log('Found Custom Fields, importing...');
return sanitiseCustomFields(data.customFields);
};
export const sanitiseCustomFields = (data: object): CustomFields => {
const newCustomFields: CustomFields = {};
for (const fieldLabel in data.customFields) {
const field = data.customFields[fieldLabel];
if (!field.label || !field.type || !field.colour) {
for (const fieldLabel in data) {
const field = data[fieldLabel];
if (!('label' in field) || field.label === '' || !('colour' in field) || typeof field.colour != 'string') {
console.log('ERROR: missing required field, skipping');
continue;
}
const key = field.label.toLowerCase();
newCustomFields[key] = {
type: field.type,
type: 'string',
colour: field.colour,
label: field.label,
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.0.0-beta.2",
"version": "3.0.0",
"description": "Time keeping for live events",
"keywords": [
"lightdev",
-2
View File
@@ -13,11 +13,9 @@
},
"dependencies": {
"deepmerge-ts": "^5.1.0",
"luxon": "^3.4.4",
"nanoid": "^5.0.4"
},
"devDependencies": {
"@types/luxon": "^3.4.0",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"eslint": "^8.53.0",
@@ -1,6 +1,6 @@
import { dayInMs } from '../timeConstants';
import { MILLIS_PER_HOUR } from './conversionUtils';
import { millisToString, removeLeadingZero } from './timeFormatting';
import { formatFromMillis, millisToString, removeLeadingZero } from './timeFormatting';
describe('millisToString()', () => {
it('returns fallback if millis is null', () => {
@@ -61,3 +61,159 @@ describe('removeLeadingZero()', () => {
expect(removeLeadingZero('-00:08:47')).toBe('-8:47');
});
});
describe('formatFromMillis()', () => {
it('milliseconds', () => {
const millis = 76211123; // Jan 1, 1970 21:10:11.123 UTC
const format = 'S';
const expectedResult = '123';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('seconds (no padding)', () => {
const millis = 76211123;
const format = 's';
const expectedResult = '11';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('seconds (padding)', () => {
const millis = 76211123;
const format = 'ss';
const expectedResult = '11';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('minute (no padding)', () => {
const millis = 76211123;
const format = 'm';
const expectedResult = '10';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('minute (padding)', () => {
const millis = 76211123;
const format = 'mm';
const expectedResult = '10';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 12 (no padding)', () => {
const millis = 76211123;
const format = 'h';
const expectedResult = '9';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 12 (padding)', () => {
const millis = 76211123;
const format = 'hh';
const expectedResult = '09';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 24 (no padding)', () => {
const millis = 76211123;
const format = 'H';
const expectedResult = '21';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 24 (padding)', () => {
const millis = 76211123;
const format = 'HH';
const expectedResult = '21';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('formatted time HH:mm:ss', () => {
const millis = 76211123;
const format = 'HH:mm:ss';
const expectedResult = '21:10:11';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('formatted time hh:mm a', () => {
const millis = 76211123;
const format = 'hh:mm a';
const expectedResult = '09:10 PM';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('milliseconds', () => {
const millis = 29167345; // 08:06:08.345
const format = 'S';
const expectedResult = '345';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('seconds (no padding)', () => {
const millis = 29167345;
const format = 's';
const expectedResult = '7';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('seconds (padding)', () => {
const millis = 29167345;
const format = 'ss';
const expectedResult = '07';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('minute (no padding)', () => {
const millis = 29167345;
const format = 'm';
const expectedResult = '6';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('minute (padding)', () => {
const millis = 29167345;
const format = 'mm';
const expectedResult = '06';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 12 (no padding)', () => {
const millis = 29167345;
const format = 'h';
const expectedResult = '8';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 12 (padding)', () => {
const millis = 29167345;
const format = 'hh';
const expectedResult = '08';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 24 (no padding)', () => {
const millis = 29167345;
const format = 'H';
const expectedResult = '8';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('hour - 24 (padding)', () => {
const millis = 29167345;
const format = 'HH';
const expectedResult = '08';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('formatted time HH:mm:ss', () => {
const millis = 29167345;
const format = 'HH:mm:ss';
const expectedResult = '08:06:07';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
it('formatted time hh:mm a', () => {
const millis = 29167345;
const format = 'hh:mm a';
const expectedResult = '08:06 AM';
expect(formatFromMillis(millis, format)).toBe(expectedResult);
});
});
@@ -1,4 +1,3 @@
import { DateTime } from 'luxon';
import type { MaybeNumber } from 'ontime-types';
import { millisToHours, millisToMinutes, millisToSeconds } from './conversionUtils.js';
@@ -71,11 +70,60 @@ export function removeSeconds(timer: string): string {
}
/**
* @description utility function to format a date in milliseconds using luxon
* @param {number} millis
* @param {string} format
* @return {string}
* Formats a given date into a custom string format based on UTC time.
*
* @param millis - The number of milliseconds.
* @param format - A string specifying the desired output format.
* For example, 'ss' will format the millis as '07' seconds.
*
* @returns The formatted date as a string according to the provided `format` string.
* If input `millis` is smaller than zero, it returns undefined.
*
*/
export function formatFromMillis(millis: number, format: string): string {
return DateTime.fromMillis(millis).toUTC().toFormat(format);
export function formatFromMillis(millis: number, format: string): string | undefined {
if (millis < 0) {
return undefined;
}
const date: Date = new Date(millis);
const hour24Padded = date.getUTCHours().toString().padStart(2, '0');
const hour24 = date.getUTCHours().toString();
const minutePadded = date.getUTCMinutes().toString().padStart(2, '0');
const minute = date.getUTCMinutes().toString();
const secondPadded = date.getUTCSeconds().toString().padStart(2, '0');
const second = date.getUTCSeconds().toString();
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0');
const hour12 = ((date.getUTCHours() % 12) || 12).toString();
const hour12Padded = hour12.padStart(2, '0');
const amPm = date.getUTCHours() >= 12 ? 'PM' : 'AM';
const replacements: Record<string, string> = {
'HH': hour24Padded,
'H': hour24,
'hh': hour12Padded,
'h': hour12,
'mm': minutePadded,
'm': minute,
'ss': secondPadded,
's': second,
'S': milliseconds,
'a': amPm
};
return applyReplacements(format, replacements);
}
/**
* Applies replacements to a template string based on a dictionary of tokens and their corresponding values.
*
* @param {string} template - The format template string containing tokens to be replaced.
* @param {Record<string, string>} replacements - A record of tokens and their corresponding values.
* @returns {string} The formatted string with all tokens replaced by their values.
*/
function applyReplacements(template: string, replacements: Record<string, string>): string {
return Object.keys(replacements).reduce((result, token) => {
const regex = new RegExp(`\\b${token}\\b`, 'g');
return result.replace(regex, replacements[token]);
}, template);
}
+174 -58
View File
@@ -68,6 +68,9 @@ importers:
'@emotion/styled':
specifier: ^11.10.6
version: 11.10.6(@emotion/react@11.10.6)(@types/react@18.0.26)(react@18.2.0)
'@fontsource/open-sans':
specifier: ^5.0.28
version: 5.0.28
'@mantine/hooks':
specifier: ^7.6.2
version: 7.6.2(react@18.2.0)
@@ -122,9 +125,6 @@ importers:
react-router-dom:
specifier: ^6.3.0
version: 6.6.2(react-dom@18.2.0)(react@18.2.0)
typeface-open-sans:
specifier: ^1.1.13
version: 1.1.13
web-vitals:
specifier: ^3.1.1
version: 3.1.1
@@ -232,8 +232,8 @@ importers:
specifier: ^28.0.0
version: 28.0.0
electron-builder:
specifier: ^24.9.1
version: 24.9.1
specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3)
eslint:
specifier: ^8.53.0
version: 8.53.0
@@ -391,16 +391,10 @@ importers:
deepmerge-ts:
specifier: ^5.1.0
version: 5.1.0
luxon:
specifier: ^3.4.4
version: 3.4.4
nanoid:
specifier: ^5.0.4
version: 5.0.4
devDependencies:
'@types/luxon':
specifier: ^3.4.0
version: 3.4.0
'@typescript-eslint/eslint-plugin':
specifier: ^6.10.0
version: 6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.4.3)
@@ -1906,8 +1900,8 @@ packages:
- supports-color
dev: true
/@electron/notarize@2.1.0:
resolution: {integrity: sha512-Q02xem1D0sg4v437xHgmBLxI2iz/fc0D4K7fiVWHa/AnW8o7D751xyKNXgziA6HrTOme9ul1JfWN5ark8WH1xA==}
/@electron/notarize@2.2.1:
resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==}
engines: {node: '>= 10.0.0'}
dependencies:
debug: 4.3.4
@@ -1932,8 +1926,8 @@ packages:
- supports-color
dev: true
/@electron/universal@1.4.1:
resolution: {integrity: sha512-lE/U3UNw1YHuowNbTmKNs9UlS3En3cPgwM5MI+agIgr/B1hSze9NdOP0qn7boZaI9Lph8IDv3/24g9IxnJP7aQ==}
/@electron/universal@1.5.1:
resolution: {integrity: sha512-kbgXxyEauPJiQQUNG2VgUeyfQNFk6hBF11ISN2PNI6agUgPl55pv4eQmaqHzTAzchBvqZ2tQuRVaPStGf0mxGw==}
engines: {node: '>=8.6'}
dependencies:
'@electron/asar': 3.2.8
@@ -2321,6 +2315,10 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@fontsource/open-sans@5.0.28:
resolution: {integrity: sha512-hBvJHY76pJT/JynGUB5EXWhnzjYfLdcMn655J5p1v9lTT9HdQSy+keq2KPVXO2Htlg998BBa3p6u/jlrZ6w0kg==}
dev: false
/@googleapis/sheets@5.0.5:
resolution: {integrity: sha512-XMoONmgAJm2jYeTYHX4054VcEkElxlgqmnHvt0wAurzEHoGJLdUHhTAJXGPLgSs4WVMPtgU8HLrmk7/U+Qlw7A==}
engines: {node: '>=12.0.0'}
@@ -3308,10 +3306,6 @@ packages:
resolution: {integrity: sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==}
dev: false
/@types/luxon@3.4.0:
resolution: {integrity: sha512-PEVoA4MOfSsFNaPrZjIUGUZujBDxnO/tj2A2N9KfzlR+pNgpBdDuk0TmRvSMAVUP5q4q8IkMEZ8UOp3MIr+QgA==}
dev: true
/@types/mime@3.0.1:
resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
dev: true
@@ -3924,25 +3918,29 @@ packages:
resolution: {integrity: sha512-xwdG0FJPQMe0M0UA4Tz0zEB8rBJTRA5a476ZawAqiBkMv16GRK5xpXThOjMaEOFnZ6zabejjG4J3da0SXG63KA==}
dev: true
/app-builder-lib@24.9.1:
resolution: {integrity: sha512-Q1nYxZcio4r+W72cnIRVYofEAyjBd3mG47o+zms8HlD51zWtA/YxJb01Jei5F+jkWhge/PTQK+uldsPh6d0/4g==}
/app-builder-lib@24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3):
resolution: {integrity: sha512-FAzX6IBit2POXYGnTCT8YHFO/lr5AapAII6zzhQO3Rw4cEDOgK+t1xhLc5tNcKlicTHlo9zxIwnYCX9X2DLkig==}
engines: {node: '>=14.0.0'}
peerDependencies:
dmg-builder: 24.13.3
electron-builder-squirrel-windows: 24.13.3
dependencies:
7zip-bin: 5.2.0
'@develar/schema-utils': 2.6.5
'@electron/notarize': 2.1.0
'@electron/notarize': 2.2.1
'@electron/osx-sign': 1.0.5
'@electron/universal': 1.4.1
'@electron/universal': 1.5.1
'@malept/flatpak-bundler': 0.4.0
'@types/fs-extra': 9.0.13
async-exit-hook: 2.0.1
bluebird-lst: 1.0.9
builder-util: 24.8.1
builder-util-runtime: 9.2.3
builder-util: 24.13.1
builder-util-runtime: 9.2.4
chromium-pickle-js: 0.2.0
debug: 4.3.4
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
ejs: 3.1.9
electron-publish: 24.8.1
electron-builder-squirrel-windows: 24.13.3(dmg-builder@24.13.3)
electron-publish: 24.13.1
form-data: 4.0.0
fs-extra: 10.1.0
hosted-git-info: 4.1.0
@@ -3964,6 +3962,38 @@ packages:
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
dev: false
/archiver-utils@2.1.0:
resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==}
engines: {node: '>= 6'}
dependencies:
glob: 7.2.3
graceful-fs: 4.2.11
lazystream: 1.0.1
lodash.defaults: 4.2.0
lodash.difference: 4.5.0
lodash.flatten: 4.4.0
lodash.isplainobject: 4.0.6
lodash.union: 4.6.0
normalize-path: 3.0.0
readable-stream: 2.3.7
dev: true
/archiver-utils@3.0.4:
resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==}
engines: {node: '>= 10'}
dependencies:
glob: 7.2.3
graceful-fs: 4.2.11
lazystream: 1.0.1
lodash.defaults: 4.2.0
lodash.difference: 4.5.0
lodash.flatten: 4.4.0
lodash.isplainobject: 4.0.6
lodash.union: 4.6.0
normalize-path: 3.0.0
readable-stream: 3.6.2
dev: true
/archiver-utils@4.0.1:
resolution: {integrity: sha512-Q4Q99idbvzmgCTEAAhi32BkOyq8iVI5EwdO0PmBDSGIzzjYNdcFn7Q7k3OzbLy4kLUPXfJtG6fO2RjftXbobBg==}
engines: {node: '>= 12.0.0'}
@@ -3976,6 +4006,19 @@ packages:
readable-stream: 3.6.2
dev: true
/archiver@5.3.2:
resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==}
engines: {node: '>= 10'}
dependencies:
archiver-utils: 2.1.0
async: 3.2.5
buffer-crc32: 0.2.13
readable-stream: 3.6.2
readdir-glob: 1.1.3
tar-stream: 2.2.0
zip-stream: 4.1.1
dev: true
/archiver@6.0.1:
resolution: {integrity: sha512-CXGy4poOLBKptiZH//VlWdFuUC1RESbdZjGjILwBuZ73P7WkAUN0htfSfBq/7k6FRFlpu7bg4JOkj1vU9G6jcQ==}
engines: {node: '>= 12.0.0'}
@@ -4143,6 +4186,14 @@ packages:
resolution: {integrity: sha512-KcSrsGiIKgklTWweVb9XnZPWO1/rGSsK3fwR7VnbDPbLKPlkvSKd/ZrJ1W712r6HzH5u0fa/AZCftATO09x8Aw==}
dev: false
/bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
dev: true
/bluebird-lst@1.0.9:
resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==}
dependencies:
@@ -4240,10 +4291,9 @@ packages:
base64-js: 1.5.1
ieee754: 1.2.1
dev: true
optional: true
/builder-util-runtime@9.2.3:
resolution: {integrity: sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==}
/builder-util-runtime@9.2.4:
resolution: {integrity: sha512-upp+biKpN/XZMLim7aguUyW8s0FUpDvOtK6sbanMFDAMBzpHDqdhgVYm6zc9HJ6nWo7u2Lxk60i2M6Jd3aiNrA==}
engines: {node: '>=12.0.0'}
dependencies:
debug: 4.3.4
@@ -4252,14 +4302,14 @@ packages:
- supports-color
dev: true
/builder-util@24.8.1:
resolution: {integrity: sha512-ibmQ4BnnqCnJTNrdmdNlnhF48kfqhNzSeqFMXHLIl+o9/yhn6QfOaVrloZ9YUu3m0k3rexvlT5wcki6LWpjTZw==}
/builder-util@24.13.1:
resolution: {integrity: sha512-NhbCSIntruNDTOVI9fdXz0dihaqX2YuE1D6zZMrwiErzH4ELZHE6mdiB40wEgZNprDia+FghRFgKoAqMZRRjSA==}
dependencies:
7zip-bin: 5.2.0
'@types/debug': 4.1.12
app-builder-bin: 4.0.0
bluebird-lst: 1.0.9
builder-util-runtime: 9.2.3
builder-util-runtime: 9.2.4
chalk: 4.1.2
cross-spawn: 7.0.3
debug: 4.3.4
@@ -4537,6 +4587,16 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/compress-commons@4.1.2:
resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==}
engines: {node: '>= 10'}
dependencies:
buffer-crc32: 0.2.13
crc32-stream: 4.0.3
normalize-path: 3.0.0
readable-stream: 3.6.2
dev: true
/compress-commons@5.0.1:
resolution: {integrity: sha512-MPh//1cERdLtqwO3pOFLeXtpuai0Y2WCd5AhtKxznqM7WtaMYaOEMSgn45d9D10sIHSfIKE603HlOp8OPGrvag==}
engines: {node: '>= 12.0.0'}
@@ -4661,6 +4721,14 @@ packages:
hasBin: true
dev: true
/crc32-stream@4.0.3:
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
engines: {node: '>= 10'}
dependencies:
crc-32: 1.2.2
readable-stream: 3.6.2
dev: true
/crc32-stream@5.0.0:
resolution: {integrity: sha512-B0EPa1UK+qnpBZpG+7FgPCu0J2ETLpXq09o9BkLkEAhdB6Z61Qo4pJ3JYu0c+Qi+/SAL7QThqnzS06pmSSyZaw==}
engines: {node: '>= 12.0.0'}
@@ -4926,18 +4994,19 @@ packages:
path-type: 4.0.0
dev: true
/dmg-builder@24.9.1:
resolution: {integrity: sha512-huC+O6hvHd24Ubj3cy2GMiGLe2xGFKN3klqVMLAdcbB6SWMd1yPSdZvV8W1O01ICzCCRlZDHiv4VrNUgnPUfbQ==}
/dmg-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
resolution: {integrity: sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==}
dependencies:
app-builder-lib: 24.9.1
builder-util: 24.8.1
builder-util-runtime: 9.2.3
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 24.13.1
builder-util-runtime: 9.2.4
fs-extra: 10.1.0
iconv-lite: 0.6.3
js-yaml: 4.1.0
optionalDependencies:
dmg-license: 1.0.11
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
dev: true
@@ -5036,16 +5105,28 @@ packages:
jake: 10.8.7
dev: true
/electron-builder@24.9.1:
resolution: {integrity: sha512-v7BuakDuY6sKMUYM8mfQGrwyjBpZ/ObaqnenU0H+igEL10nc6ht049rsCw2HghRBdEwJxGIBuzs3jbEhNaMDmg==}
/electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3):
resolution: {integrity: sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==}
dependencies:
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
archiver: 5.3.2
builder-util: 24.13.1
fs-extra: 10.1.0
transitivePeerDependencies:
- dmg-builder
- supports-color
dev: true
/electron-builder@24.13.3(electron-builder-squirrel-windows@24.13.3):
resolution: {integrity: sha512-yZSgVHft5dNVlo31qmJAe4BVKQfFdwpRw7sFp1iQglDRCDD6r22zfRJuZlhtB5gp9FHUxCMEoWGq10SkCnMAIg==}
engines: {node: '>=14.0.0'}
hasBin: true
dependencies:
app-builder-lib: 24.9.1
builder-util: 24.8.1
builder-util-runtime: 9.2.3
app-builder-lib: 24.13.3(dmg-builder@24.13.3)(electron-builder-squirrel-windows@24.13.3)
builder-util: 24.13.1
builder-util-runtime: 9.2.4
chalk: 4.1.2
dmg-builder: 24.9.1
dmg-builder: 24.13.3(electron-builder-squirrel-windows@24.13.3)
fs-extra: 10.1.0
is-ci: 3.0.1
lazy-val: 1.0.5
@@ -5053,15 +5134,16 @@ packages:
simple-update-notifier: 2.0.0
yargs: 17.7.2
transitivePeerDependencies:
- electron-builder-squirrel-windows
- supports-color
dev: true
/electron-publish@24.8.1:
resolution: {integrity: sha512-IFNXkdxMVzUdweoLJNXSupXkqnvgbrn3J4vognuOY06LaS/m0xvfFYIf+o1CM8if6DuWYWoQFKPcWZt/FUjZPw==}
/electron-publish@24.13.1:
resolution: {integrity: sha512-2ZgdEqJ8e9D17Hwp5LEq5mLQPjqU3lv/IALvgp+4W8VeNhryfGhYEQC/PgDPMrnWUp+l60Ou5SJLsu+k4mhQ8A==}
dependencies:
'@types/fs-extra': 9.0.13
builder-util: 24.8.1
builder-util-runtime: 9.2.3
builder-util: 24.13.1
builder-util-runtime: 9.2.4
chalk: 4.1.2
fs-extra: 10.1.0
lazy-val: 1.0.5
@@ -5874,6 +5956,10 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
dev: true
/fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -6398,7 +6484,6 @@ packages:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
requiresBuild: true
dev: true
optional: true
/ignore-by-default@1.0.1:
resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==}
@@ -6981,6 +7066,22 @@ packages:
p-locate: 5.0.0
dev: true
/lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
dev: true
/lodash.difference@4.5.0:
resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==}
dev: true
/lodash.flatten@4.4.0:
resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==}
dev: true
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
@@ -6989,6 +7090,10 @@ packages:
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
dev: false
/lodash.union@4.6.0:
resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==}
dev: true
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -7056,11 +7161,6 @@ packages:
yallist: 4.0.0
dev: true
/luxon@3.4.4:
resolution: {integrity: sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==}
engines: {node: '>=12'}
dev: false
/lz-string@1.4.4:
resolution: {integrity: sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==}
hasBin: true
@@ -8728,6 +8828,17 @@ packages:
tslib: 2.6.2
dev: true
/tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
dependencies:
bl: 4.1.0
end-of-stream: 1.4.4
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
dev: true
/tar-stream@3.1.7:
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
dependencies:
@@ -9053,10 +9164,6 @@ packages:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
dev: false
/typeface-open-sans@1.1.13:
resolution: {integrity: sha512-lVGVHvYl7UJDFB9vN8r7NHw3sVm7Rjeow6b9AeABc/J+2mDaCkmcdVtw3QZnsJW39P+xm5zeggIj9gLHYGn9Iw==}
dev: false
/typescript@4.9.5:
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
engines: {node: '>=4.2.0'}
@@ -9634,6 +9741,15 @@ packages:
engines: {node: '>=12.20'}
dev: true
/zip-stream@4.1.1:
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
engines: {node: '>= 10'}
dependencies:
archiver-utils: 3.0.4
compress-commons: 4.1.2
readable-stream: 3.6.2
dev: true
/zip-stream@5.0.1:
resolution: {integrity: sha512-UfZ0oa0C8LI58wJ+moL46BDIMgCQbnsb+2PoiJYtonhBsMh2bq1eRBVkvjfVsqbEHd9/EgKPUuL9saSSsec8OA==}
engines: {node: '>= 12.0.0'}