Compare commits

...

17 Commits

Author SHA1 Message Date
Carlos Valente 9c910c079d bump version to 3.10.4 2025-01-19 20:04:52 +01:00
Carlos Valente 420cef8b0d chore: add html files to docker 2025-01-19 19:57:10 +01:00
Carlos Valente b4c7b84f92 refactor: handle trailing slash in URL preset 2025-01-19 19:57:10 +01:00
Carlos Valente 0450eb9169 refactor: set token from query params 2025-01-19 19:57:10 +01:00
Carlos Valente 7fe5223af9 feat: add auth to server requests 2025-01-19 19:57:10 +01:00
arc-alex ce8d534953 feat: optional welcome modal
welcome modal is persisted in app state

created endpoints to set the visibility

added UI to support feature

Co-authored-by: Carlos Valente
<34649812+cpvalente@users.noreply.github.com>
2025-01-18 20:24:46 +01:00
Carlos Valente 4a6fbc2a92 refactor: add timezone data to session 2025-01-17 20:05:56 +01:00
Carlos Valente 50ebcf4e14 refactor: review welcome modal design 2025-01-17 20:04:00 +01:00
Carlos Valente f0f7d1cede docs: add homebrew links 2025-01-17 20:02:44 +01:00
Carlos Valente d2bdb1ea03 docs: add links to sponsor 2025-01-17 20:02:44 +01:00
Carlos Valente 9be6febcda chore: upgrade playwright 2025-01-17 19:57:01 +01:00
Carlos Valente aa929fd0dc refactor: create utility for nested templates 2025-01-12 10:20:24 +01:00
Carlos Valente 7baa6a4ab9 refactor: create mock data utilities 2025-01-12 10:20:24 +01:00
Carlos Valente 082fe8b8d7 chore: write feature spec 2025-01-12 10:14:44 +01:00
Alex Christoffer Rasmussen e9a7cd0400 More rundown metadata (#1429)
* add dayOffset to OntimeEvent

* refactor: isNewLatest

* calculate totalDays

* refactor: getTimeFromPrevious

* add gap as dataset in OntimeEvent

* use in ui

* format overlap is just a simple text formatting, big test is not needed

* add new fields where needed

* update apply delay

* show nex day eaven if there is no gap

* create test

* use buildin day offset in timeline

* remove todo

* consistent naming

* make a calculateDayOffset util for rundownCache

* refactor: checkIsNextDay to use dayOffset

* spelling

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

* remove unneeded test

* remove todo

* update test db

* use data-testid

* spelling

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

* update comments

* remove null option

---------

Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
2025-01-09 13:35:11 +01:00
Carlos Valente 9ba40c35a1 refactor: avoid matching file versions 2025-01-06 22:58:06 +01:00
Carlos Valente f94db06812 refactor: wider sheet id length 2025-01-06 22:58:06 +01:00
72 changed files with 1515 additions and 796 deletions
+1
View File
@@ -24,6 +24,7 @@ COPY --from=builder /app/apps/client/build ./client/
COPY --from=builder /app/apps/server/dist/ ./server/
COPY --from=builder /app/apps/server/src/external/ ./external/
COPY --from=builder /app/apps/server/src/user/ ./user/
COPY --from=builder /app/apps/server/src/html/ ./html/
# Export default ports
EXPOSE 4001/tcp 8888/udp 9999/udp
+5
View File
@@ -2,6 +2,7 @@
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/cpvalente/ontime/total)
![Docker Pulls](https://img.shields.io/docker/pulls/getontime/ontime)
![NPM Downloads](https://img.shields.io/npm/dy/%40getontime%2Fcli)
![Homebrew Cask Version](https://img.shields.io/homebrew/cask/v/ontime)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/cpvalente)
[![](https://img.shields.io/static/v1?label=Buy%20me%20a%20coffee&message=%E2%9D%A4&logo=buymeacoffee&color=%23fe8e86)](https://www.buymeacoffee.com/cpvalente)
@@ -12,7 +13,11 @@
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-arm64.dmg">MacOS Arm</a>
- Download for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-macOS-x64.dmg">MacOS Intel</a>
- Download AppImage for <a href="https://github.com/cpvalente/ontime/releases/latest/download/ontime-linux.AppImage">Linux</a>
... or
- Get from <a href="https://hub.docker.com/r/getontime/ontime">Docker hub</a>
- Install from <a href="https://www.npmjs.com/package/ontime">NPM</a>
- Install from <a href="https://formulae.brew.sh/cask/ontime">Homebrew</a>
## Need help?
We do our best to have most topics covered by the documentation. However, if your question is not covered, you are welcome to [fill in a bug report in an issue](https://github.com/cpvalente/ontime/issues), [ask a question in GitHub discussions](https://github.com/cpvalente/ontime/discussions) or hop in the [discord server](https://discord.com/invite/eje3CSUEXm) for a chat.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.10.3",
"version": "3.10.4",
"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.10.3",
"version": "3.10.4",
"private": true,
"type": "module",
"dependencies": {
+7
View File
@@ -19,3 +19,10 @@ export async function getSettings(): Promise<Settings> {
export async function postSettings(data: Settings): Promise<AxiosResponse<Settings>> {
return axios.post(settingsPath, data);
}
/**
* Allows setting the welcome modal dialog state from the clients
*/
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
@@ -25,6 +25,8 @@ export const cloneEvent = (event: OntimeEvent): ClonedEvent => {
colour: event.colour,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: event.timeWarning,
timeDanger: event.timeDanger,
custom: {},
+8 -1
View File
@@ -28,6 +28,13 @@ export const validateUrlPresetPath = (preset: string): { message: string; isVali
return { isValid: true, message: 'ok' };
};
/**
* Utility removes trailing slash from a string
*/
function removeTrailingSlash(text: string): string {
return text.replace(/\/$/, '');
}
/**
* Gets the URL to send a preset to
* @param location
@@ -38,7 +45,7 @@ export const getRouteFromPreset = (location: Location, data: URLPreset[], search
const currentURL = location.pathname.substring(1);
// we need to check if the whole url here is an alias, so we can redirect
const foundPreset = data.filter((d) => d.alias === currentURL && d.enabled)[0];
const foundPreset = data.find((preset) => preset.alias === removeTrailingSlash(currentURL) && preset.enabled);
if (foundPreset) {
return generateUrlFromPreset(foundPreset);
}
+3
View File
@@ -12,6 +12,9 @@ export const discordUrl = 'https://discord.com/invite/eje3CSUEXm';
export const documentationUrl = 'https://docs.getontime.no';
export const customFieldsDocsUrl = 'https://docs.getontime.no/features/custom-fields/';
export const githubSponsorUrl = 'https://github.com/sponsors/cpvalente';
export const buyMeACoffeeUrl = 'https://buymeacoffee.com/cpvalente';
// resolve environment
export const appVersion = version;
export const isProduction = import.meta.env.MODE === 'production';
@@ -1,5 +1,12 @@
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { discordUrl, documentationUrl, githubUrl, websiteUrl } from '../../../../externals';
import {
buyMeACoffeeUrl,
discordUrl,
documentationUrl,
githubSponsorUrl,
githubUrl,
websiteUrl,
} from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import AppVersion from './AppVersion';
@@ -9,22 +16,29 @@ export default function AboutPanel() {
<>
<Panel.Header>About Ontime</Panel.Header>
<Panel.Section>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Section>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Ontime</Panel.SubHeader>
<Panel.Paragraph>
Free, open-source software for managing rundowns and event timers
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
Considering sponsoring our work
<ExternalLink href={githubSponsorUrl}>GitHub Sponsors</ExternalLink>
<ExternalLink href={buyMeACoffeeUrl}>Buy Me a Coffee</ExternalLink>
</Panel.Paragraph>
<Panel.Paragraph>
And trying out our cloud service
<ExternalLink href={websiteUrl}>www.getontime.no</ExternalLink>
</Panel.Paragraph>
</Panel.Card>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
<Panel.SubHeader>Links</Panel.SubHeader>
<ExternalLink href={documentationUrl}>Read the docs</ExternalLink>
<ExternalLink href={githubUrl}>Follow the project on GitHub</ExternalLink>
<ExternalLink href={discordUrl}>Discord server</ExternalLink>
</Panel.Section>
<Panel.Section>
<Panel.SubHeader>Current version</Panel.SubHeader>
<AppVersion />
</Panel.Section>
</>
);
}
@@ -24,6 +24,10 @@
display: flex;
gap: 1rem;
justify-content: end;
:first-child {
margin-right: auto;
}
}
.tableContainer {
@@ -38,14 +42,20 @@
tbody {
background-color: $gray-1300;
tr {
&:has(:hover) {
&:hover:not(.current) {
background-color: $gray-1350;
}
.current {
&:hover {
background-color: $blue-900;
}
}
}
}
tr {
height: 2rem;
cursor: pointer;
}
th,
@@ -62,4 +72,8 @@
.current {
background-color: $blue-700;
&:hover {
background-color: $blue-900;
}
}
@@ -1,10 +1,12 @@
import { useNavigate } from 'react-router-dom';
import { Button, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { Button, Checkbox, Modal, ModalBody, ModalCloseButton, ModalContent, ModalOverlay } from '@chakra-ui/react';
import { loadDemo, loadProject } from '../../../common/api/db';
import { postShowWelcomeDialog } from '../../../common/api/settings';
import { invalidateAllCaches } from '../../../common/api/utils';
import ExternalLink from '../../../common/components/external-link/ExternalLink';
import { appVersion, discordUrl, documentationUrl, websiteUrl } from '../../../externals';
import * as Editor from '../editor-utils/EditorUtils';
import ImportProjectButton from './composite/ImportProjectButton';
import WelcomeProjectList from './composite/WelcomeProjectList';
@@ -68,30 +70,37 @@ export default function Welcome(props: WelcomeProps) {
</div>
<div className={style.column}>
<div className={style.header}>Welcome to Ontime</div>
<Editor.Title>Select project</Editor.Title>
<div className={style.tableContainer}>
<table className={style.table}>
<thead>
<tr>
<th>Project Name</th>
<th>Last Used</th>
<th />
</tr>
</thead>
<WelcomeProjectList loadProject={handleLoadProject} onClose={handleClose} />
</table>
</div>
<div className={style.buttonRow}>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
</div>
</div>
<div className={style.buttonRow}>
<Button size='sm' variant='ontime-subtle' onClick={handleLoadDemo}>
Load demo project
</Button>
<ImportProjectButton onFinish={handleClose} />
<Button size='sm' variant='ontime-filled' onClick={handleCallCreate}>
Create new...
</Button>
</div>
<Checkbox
size='sm'
variant='ontime-ondark'
checked
onChange={(event) => postShowWelcomeDialog(event.target.checked)}
>
Show this modal on next startup
</Checkbox>
</ModalBody>
</ModalContent>
</Modal>
@@ -1,5 +1,3 @@
import { Button } from '@chakra-ui/react';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import style from '../Welcome.module.scss';
@@ -18,26 +16,16 @@ export default function WelcomeProjectList(props: WelcomeProjectListProps) {
{data.reorderedProjectFiles.map((project) => {
if (project.filename === data.lastLoadedProject) {
return (
<tr className={style.current} key={project.filename}>
<tr className={style.current} key={project.filename} onClick={onClose}>
<td>{project.filename}</td>
<td>Loaded from last session</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Continue
</Button>
</td>
</tr>
);
}
return (
<tr key={project.filename}>
<tr key={project.filename} onClick={() => loadProject(project.filename)}>
<td>{project.filename}</td>
<td>{new Date(project.updatedAt).toLocaleString()}</td>
<td>
<Button variant='ontime-subtle' size='sm' onClick={() => loadProject(project.filename)}>
Load
</Button>
</td>
</tr>
);
})}
+7 -5
View File
@@ -13,6 +13,7 @@ import {
SupportedEvent,
} from 'ontime-types';
import {
checkIsNextDay,
getFirstNormal,
getLastNormal,
getNextBlockNormal,
@@ -267,7 +268,7 @@ export default function Rundown({ data }: RundownProps) {
let eventIndex = 0;
// all events before the current selected are in the past
let isPast = Boolean(featureData?.selectedEventId);
let isNextDay = false;
const isEditMode = appMode === AppMode.Edit;
return (
@@ -286,6 +287,7 @@ export default function Rundown({ data }: RundownProps) {
if (index === 0) {
eventIndex = 0;
}
isNextDay = false;
previousEntryId = thisId;
thisId = entryId;
if (isOntimeEvent(entry)) {
@@ -294,8 +296,9 @@ export default function Rundown({ data }: RundownProps) {
lastEvent = thisEvent;
if (isPlayableEvent(entry)) {
// populate previous entry
if (isNewLatest(entry.timeStart, entry.timeEnd, lastEvent?.timeStart, lastEvent?.timeEnd)) {
isNextDay = checkIsNextDay(entry, lastEvent);
if (isNewLatest(entry, lastEvent)) {
// populate previous entry
thisEvent = entry;
}
}
@@ -323,12 +326,11 @@ export default function Rundown({ data }: RundownProps) {
loaded={isLoaded}
hasCursor={hasCursor}
isNext={isNext}
previousStart={lastEvent?.timeStart}
previousEnd={lastEvent?.timeEnd}
previousEntryId={previousEntryId}
previousEventId={lastEvent?.id}
playback={isLoaded ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
isNextDay={isNextDay}
/>
</div>
</div>
@@ -32,8 +32,7 @@ interface RundownEntryProps {
eventIndex: number;
hasCursor: boolean;
isNext: boolean;
previousStart?: number;
previousEnd?: number;
isNextDay: boolean;
previousEntryId?: string;
previousEventId?: string;
playback?: Playback; // we only care about this if this event is playing
@@ -47,13 +46,12 @@ export default function RundownEntry(props: RundownEntryProps) {
loaded,
hasCursor,
isNext,
previousStart,
previousEnd,
previousEntryId,
previousEventId,
playback,
isRolling,
eventIndex,
isNextDay,
} = props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, batchUpdateEvents, deleteEvent, swapEvents } = useEventAction();
@@ -165,8 +163,6 @@ export default function RundownEntry(props: RundownEntryProps) {
title={data.title}
note={data.note}
delay={data.delay ?? 0}
previousStart={previousStart}
previousEnd={previousEnd}
colour={data.colour}
isPast={isPast}
isNext={isNext}
@@ -175,6 +171,8 @@ export default function RundownEntry(props: RundownEntryProps) {
hasCursor={hasCursor}
playback={playback}
isRolling={isRolling}
gap={data.gap}
isNextDay={isNextDay}
actionHandler={actionHandler}
/>
);
@@ -39,8 +39,6 @@ interface EventBlockProps {
title: string;
note: string;
delay: number;
previousStart?: number;
previousEnd?: number;
colour: string;
isPast: boolean;
isNext: boolean;
@@ -49,6 +47,8 @@ interface EventBlockProps {
hasCursor: boolean;
playback?: Playback;
isRolling: boolean;
gap: number;
isNextDay: boolean;
actionHandler: (
action: EventItemActions,
payload?:
@@ -77,8 +77,6 @@ export default function EventBlock(props: EventBlockProps) {
title,
note,
delay,
previousStart,
previousEnd,
colour,
isPast,
isNext,
@@ -87,6 +85,8 @@ export default function EventBlock(props: EventBlockProps) {
hasCursor,
playback,
isRolling,
gap,
isNextDay,
actionHandler,
} = props;
const { selectedEventId, setSelectedEventId, clearSelectedEventId } = useEventIdSwapping();
@@ -273,7 +273,7 @@ export default function EventBlock(props: EventBlockProps) {
onContextMenu={onContextMenu}
id='event-block'
>
<RundownIndicators timeStart={timeStart} previousStart={previousStart} previousEnd={previousEnd} delay={delay} />
<RundownIndicators timeStart={timeStart} delay={delay} gap={gap} isNextDay={isNextDay} />
<div className={style.binder} style={{ ...binderColours }} tabIndex={-1}>
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
@@ -1,11 +1,4 @@
import {
calculateDuration,
checkIsNextDay,
dayInMs,
getTimeFromPrevious,
millisToString,
removeTrailingZero,
} from 'ontime-utils';
import { millisToString, removeTrailingZero } from 'ontime-utils';
import { formatDuration } from '../../../common/utils/time';
@@ -17,25 +10,15 @@ export function formatDelay(timeStart: number, delay: number): string | undefine
const timeTag = removeTrailingZero(millisToString(delayedStart));
return `New start ${timeTag}`;
}
export function formatOverlap(timeStart: number, previousStart?: number, previousEnd?: number): string | undefined {
const noPreviousElement = previousEnd === undefined || previousStart === undefined;
if (noPreviousElement) return;
const normalisedDuration = calculateDuration(previousStart, previousEnd);
const timeFromPrevious = getTimeFromPrevious(timeStart, previousStart, previousEnd, normalisedDuration);
if (timeFromPrevious === 0) return;
if (checkIsNextDay(previousStart, timeStart, normalisedDuration)) {
const previousCrossMidnight = previousStart > previousEnd;
const normalisedPreviousEnd = previousCrossMidnight ? previousEnd + dayInMs : previousEnd;
const gap = dayInMs - normalisedPreviousEnd + timeStart;
if (gap === 0) return;
const gapString = formatDuration(Math.abs(gap), false);
return `Gap ${gapString} (next day)`;
export function formatGap(gap: number, isNextDay: boolean) {
if (gap === 0) {
if (isNextDay) {
// We show a next day warning even if there is no gap
return '(next day)';
}
return;
}
const overlapString = formatDuration(Math.abs(timeFromPrevious), false);
return `${timeFromPrevious < 0 ? 'Overlap' : 'Gap'} ${overlapString}`;
const gapString = formatDuration(Math.abs(gap), false);
return `${gap < 0 ? 'Overlap' : 'Gap'} ${gapString}${isNextDay ? ' (next day)' : ''}`;
}
@@ -1,24 +1,24 @@
import { formatDelay, formatOverlap } from './EventBlock.utils';
import { formatDelay, formatGap } from './EventBlock.utils';
import style from './RundownIndicators.module.scss';
interface RundownIndicatorProps {
timeStart: number;
previousStart?: number;
previousEnd?: number;
isNextDay: boolean;
delay: number;
gap: number;
}
export default function RundownIndicators(props: RundownIndicatorProps) {
const { timeStart, previousStart, previousEnd, delay } = props;
const { timeStart, delay, gap, isNextDay } = props;
const hasOverlap = formatOverlap(timeStart, previousStart, previousEnd);
const hasGap = formatGap(gap, isNextDay);
const hasDelay = formatDelay(timeStart, delay);
return (
<div className={style.indicators}>
{hasDelay && <div className={style.delay}>{hasDelay}</div>}
{hasOverlap && <div className={style.gap}>{hasOverlap}</div>}
{hasGap && <div className={style.gap}>{hasGap}</div>}
</div>
);
}
@@ -1,6 +1,4 @@
import { MILLIS_PER_HOUR } from 'ontime-utils';
import { formatDelay, formatOverlap } from '../EventBlock.utils';
import { formatDelay } from '../EventBlock.utils';
describe('formatDelay()', () => {
it('adds a given delay to the start time', () => {
@@ -10,69 +8,3 @@ describe('formatDelay()', () => {
expect(result).toEqual('New start 00:02');
});
});
describe('formatOverlap()', () => {
it('recognises an overlap between two times', () => {
const previousStart = 0;
const previousEnd = 60000; // 1 min
const timeStart = 30000; // 30 sec
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 30s');
});
it('bug #949 recognises an overlap between two times', () => {
const previousStart = 46800000; // 13:00:00
const previousEnd = 48600000; // 13:30:00
const timeStart = 48300000; // 13:25:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toEqual('Overlap 5m');
});
it('handles events the day after, without overlap', () => {
const previousStart = 11 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const timeStart = 6 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 18h (next day)');
});
it('handles events the day after, with gap', () => {
const previousStart = 17 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const timeStart = 9 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 10h (next day)');
});
it('handles events the day after, with previous ending at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR; // 23:00:00
const previousEnd = 0; // 00:00:00
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00:00
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h (next day)');
});
it('handles sequential events the day after, with previous ending over midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBeUndefined();
});
it('handles events the day after, with previous ending over midnight with overlap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const timeStart = 1 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Overlap 1h');
});
it('handles events the day after, with previous ending over midnight with gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const result = formatOverlap(timeStart, previousStart, previousEnd);
expect(result).toBe('Gap 1h');
});
});
@@ -6,6 +6,7 @@ import { IoLockClosed } from '@react-icons/all-files/io5/IoLockClosed';
import { IoLockOpenOutline } from '@react-icons/all-files/io5/IoLockOpenOutline';
import { IoUnlink } from '@react-icons/all-files/io5/IoUnlink';
import { MaybeString, TimeField, TimeStrategy } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import TimeInputWithButton from '../../../common/components/input/time-input/TimeInputWithButton';
import { useEventAction } from '../../../common/hooks/useEventAction';
@@ -45,7 +46,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
};
const warnings = [];
if (timeStart > timeEnd) {
if (timeStart + duration > dayInMs) {
warnings.push('Over midnight');
}
@@ -129,7 +130,7 @@ function TimeInputFlow(props: EventBlockTimerProps) {
</div>
{warnings.length > 0 && (
<div className={style.timerNote}>
<div className={style.timerNote} data-testid='event-warning'>
<Tooltip label={warnings.join(' - ')} openDelay={tooltipDelayFast} variant='ontime-ondark' shouldWrapChildren>
<IoAlertCircleOutline />
</Tooltip>
+3 -15
View File
@@ -1,7 +1,7 @@
import { memo } from 'react';
import { useViewportSize } from '@mantine/hooks';
import { isOntimeEvent, isPlayableEvent, MaybeNumber, OntimeRundown } from 'ontime-types';
import { checkIsNextDay, dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import { isOntimeEvent, isPlayableEvent, OntimeRundown } from 'ontime-types';
import { dayInMs, getLastEvent, MILLIS_PER_HOUR } from 'ontime-utils';
import TimelineMarkers from './timeline-markers/TimelineMarkers';
import { getElementPosition, getEndHour, getStartHour } from './timeline.utils';
@@ -30,10 +30,8 @@ function Timeline(props: TimelineProps) {
const startHour = getStartHour(firstStart);
const endHour = getEndHour(firstStart + totalDuration + (lastEvent?.delay ?? 0));
let previousEventStartTime: MaybeNumber = null;
// we use selectedEventId as a signifier on whether the timeline is live
let eventStatus: ProgressStatus = selectedEventId ? 'done' : 'future';
let elapsedDays = 0;
return (
<div className={style.timeline}>
@@ -52,14 +50,7 @@ function Timeline(props: TimelineProps) {
eventStatus = 'live';
}
// we only need to check for next day if we have a previous event
if (
previousEventStartTime !== null &&
checkIsNextDay(previousEventStartTime, event.timeStart, event.duration)
) {
elapsedDays++;
}
const normalisedStart = event.timeStart + elapsedDays * dayInMs;
const normalisedStart = event.timeStart + event.dayOffset * dayInMs;
const { left: elementLeftPosition, width: elementWidth } = getElementPosition(
startHour * MILLIS_PER_HOUR,
@@ -69,9 +60,6 @@ function Timeline(props: TimelineProps) {
screenWidth,
);
// prepare values for next iteration
previousEventStartTime = normalisedStart;
return (
<TimelineEntry
key={event.id}
@@ -110,7 +110,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
let selectedIndex = selectedEventId ? Infinity : -1;
let firstStart = null;
let totalDuration = 0;
let lastEntry: PlayableEvent | null = null;
let lastEntry: PlayableEvent | undefined;
for (let i = 0; i < rundown.length; i++) {
const currentEntry = rundown[i];
@@ -142,12 +142,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
const timeFromPrevious: number = getTimeFromPrevious(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
totalDuration += currentEntry.duration;
@@ -156,7 +151,7 @@ export function useScopedRundown(rundown: OntimeRundown, selectedEventId: MaybeS
} else if (timeFromPrevious < 0) {
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
}
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.10.3",
"version": "3.10.4",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+3 -1
View File
@@ -2,10 +2,12 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.10.3",
"version": "3.10.4",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.1",
"express": "^4.21.1",
+12 -5
View File
@@ -25,6 +25,7 @@ import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { generateId } from 'ontime-utils';
import { authenticateSocket } from '../middleware/authenticate.js';
let instance: SocketServer | null = null;
@@ -34,7 +35,7 @@ export class SocketServer implements IAdapter {
private wss: WebSocketServer | null;
private readonly clients: Map<string, Client>;
private lastConnection: Date | null = null;
private isFirstEditor = true;
private shouldShowWelcome = true;
constructor() {
if (instance) {
@@ -47,10 +48,16 @@ export class SocketServer implements IAdapter {
this.wss = null;
}
init(server: Server, prefix?: string) {
init(server: Server, showWelcome: boolean, prefix?: string) {
this.shouldShowWelcome = showWelcome;
this.wss = new WebSocketServer({ path: `${prefix}/ws`, server, maxPayload: this.MAX_PAYLOAD });
this.wss.on('connection', (ws) => {
this.wss.on('connection', (ws, req) => {
authenticateSocket(ws, req, (error) => {
if (error) {
ws.close(1008, 'Unauthorized');
}
});
const clientId = generateId();
this.clients.set(clientId, {
@@ -136,8 +143,8 @@ export class SocketServer implements IAdapter {
previousData.path = payload;
this.clients.set(clientId, previousData);
if (payload.includes('editor') && this.isFirstEditor) {
this.isFirstEditor = false;
if (payload.includes('editor') && this.shouldShowWelcome) {
this.shouldShowWelcome = false;
ws.send(
JSON.stringify({
type: 'dialog',
@@ -7,6 +7,7 @@ import { getLastRequest } from '../../api-integration/integration.controller.js'
import { getLastLoadedProject } from '../../services/app-state-service/AppStateService.js';
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
import { getNetworkInterfaces } from '../../utils/network.js';
import { getTimezoneLabel } from '../../utils/time.js';
const startedAt = new Date();
@@ -24,7 +25,7 @@ export async function getSessionStats(): Promise<SessionStats> {
lastRequest: lastRequest !== null ? lastRequest.toISOString() : null,
projectName,
playback,
timezone: startedAt.getTimezoneOffset(),
timezone: getTimezoneLabel(startedAt),
};
}
@@ -6,6 +6,7 @@ import type { Request, Response } from 'express';
import { isDocker } from '../../externals.js';
import { failEmptyObjects } from '../../utils/routerUtils.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import * as appState from '../../services/app-state-service/AppStateService.js';
import { extractPin } from './settings.utils.js';
@@ -65,3 +66,8 @@ export async function postSettings(req: Request, res: Response<Settings | ErrorR
res.status(400).send({ message });
}
}
export async function postWelcomeDialog(req: Request, res: Response) {
const show = await appState.setShowWelcomeDialog(req.body.show);
res.status(200).send({ show });
}
@@ -1,8 +1,10 @@
import express from 'express';
import { getSettings, postSettings } from './settings.controller.js';
import { validateSettings } from './settings.validation.js';
import { getSettings, postSettings, postWelcomeDialog } from './settings.controller.js';
import { validateSettings, validateWelcomeDialog } from './settings.validation.js';
export const router = express.Router();
router.post('/welcomedialog', validateWelcomeDialog, postWelcomeDialog);
router.get('/', getSettings);
router.post('/', validateSettings, postSettings);
@@ -1,6 +1,18 @@
import { body, validationResult } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
/**
* @description Validates object for POST /ontime/settings/welcomedialog
*/
export const validateWelcomeDialog = [
body('show').exists().isBoolean(),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
next();
},
];
/**
* @description Validates object for POST /ontime/settings
*/
@@ -8,7 +8,7 @@ export const validateRequestConnection = [
.exists()
.isString()
.isLength({
min: 40,
min: 20,
max: 100,
})
.withMessage('Sheet ID is usually 44 characters long'),
+22 -19
View File
@@ -5,16 +5,18 @@ import express from 'express';
import http, { Server } from 'http';
import cors from 'cors';
import serverTiming from 'server-timing';
import cookieParser from 'cookie-parser';
// import utils
import { publicDir, srcDir } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
// Import middleware configuration
import { bodyParser } from './middleware/bodyParser.js';
import { compressedStatic } from './middleware/staticGZip.js';
// import utils
import { publicDir, srcDir, srcFiles } from './setup/index.js';
import { environment, isOntimeCloud, isProduction, updateRouterPrefix } from './externals.js';
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
import { consoleSuccess, consoleHighlight, consoleError } from './utils/console.js';
import { loginRouter, makeAuthenticateMiddleware } from './middleware/authenticate.js';
// Import Routers
import { appRouter } from './api-data/index.js';
@@ -44,6 +46,7 @@ import { clearUploadfolder } from './utils/upload.js';
import { generateCrashReport } from './utils/generateCrashReport.js';
import { timerConfig } from './config/config.js';
import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js';
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
console.log('\n');
consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`);
@@ -72,19 +75,18 @@ if (!isProduction) {
}
app.disable('x-powered-by');
// setup cors for all routes
app.use(cors());
// enable pre-flight cors
app.options('*', cors());
// Implement middleware
app.use(cors()); // setup cors for all routes
app.options('*', cors()); // enable pre-flight cors
app.use(bodyParser);
app.use(prefix, compressedStatic);
app.use(cookieParser());
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware(prefix);
// Implement route endpoints
app.use(`${prefix}/data`, appRouter); // router for application data
app.use(`${prefix}/api`, integrationRouter); // router for integrations
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
// serve static external files
app.use(`${prefix}/external`, express.static(publicDir.externalDir));
@@ -94,9 +96,9 @@ app.use(`${prefix}/external`, (req, res) => {
});
app.use(`${prefix}/user`, express.static(publicDir.userDir));
app.get(`${prefix}/*`, (_req, res) => {
res.sendFile(srcFiles.clientIndexHtml);
});
// Base route for static files
app.use(`${prefix}`, authenticateAndRedirect, compressedStatic);
app.use(`${prefix}/*`, authenticateAndRedirect, compressedStatic);
// Implement catch all
app.use((_error, response) => {
@@ -166,8 +168,9 @@ export const startServer = async (
// the express server must be started before the socket otherwise the on error event listener will not attach properly
const resultPort = await serverTryDesiredPort(expressServer, desiredPort);
await getDataProvider().setSettings({ ...settings, serverPort: resultPort });
const showWelcome = await getShowWelcomeDialog();
socket.init(expressServer, prefix);
socket.init(expressServer, showWelcome, prefix);
/**
* Module initialises the services and provides initial payload for the store
+2
View File
@@ -15,6 +15,8 @@ export const environment = isTest ? 'test' : env;
export const isDocker = env === 'docker';
export const isProduction = isDocker || (env === 'production' && !isTest);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const password = process.env.SESSION_PASSWORD;
/**
* Updates the router prefix in the index.html file
* This is only needed in the cloud environment where the client is not at the root segment
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex" />
<meta http-equiv="X-Content-Type-Options" content="nosniff" />
<meta name="description" content="Login page for Ontime application" />
<title>Ontime Login</title>
<style>
body,
html {
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana,
sans-serif;
background: #101010;
text-align: center;
box-sizing: border-box;
}
h1 {
padding-top: 30vh;
font-size: 3rem;
color: #ff7597;
font-weight: 400;
}
input {
all: unset;
text-align: start;
padding-left: 0.5rem;
background-color: #fffffa;
color: black;
height: 2rem;
border-radius: 2px;
margin-right: 0.5rem;
}
input:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
color: #262626;
}
button {
all: unset;
color: #779be7;
background: #2d2d2d;
height: 2rem;
padding-inline: 2rem;
border-radius: 2px;
cursor: pointer;
}
button:hover {
background: #404040;
}
button:focus {
outline: 2px solid #779be7;
outline-offset: 2px;
}
</style>
</head>
<body>
<form method="post" class="form" autocomplete="off">
<h1>Welcome to Ontime</h1>
<input type="hidden" name="redirect" value="" id="redirect" />
<input type="password" name="password" placeholder="Password" required autocomplete="current-password" />
<button type="submit">Login</button>
</form>
</body>
<script>
const params = new URLSearchParams(window.location.search);
const redirect = params.get('redirect');
if (redirect) {
// Validate redirect URL to prevent open redirect attacks
const isValidRedirect = redirect.startsWith('/') && !redirect.startsWith('//');
if (isValidRedirect) {
document.getElementById('redirect').value = redirect;
}
}
</script>
</html>
+131
View File
@@ -0,0 +1,131 @@
import { LogOrigin } from 'ontime-types';
import express, { type Request, type Response, type NextFunction } from 'express';
import type { IncomingMessage } from 'node:http';
import type { WebSocket } from 'ws';
import { parse as parseCookie } from 'cookie';
import { hashPassword } from '../utils/hash.js';
import { srcFiles } from '../setup/index.js';
import { logger } from '../classes/Logger.js';
import { password } from '../externals.js';
import { noopMiddleware } from './noop.js';
export const hasPassword = Boolean(password);
const hashedPassword = hasPassword ? hashPassword(password) : '';
/**
* List of public assets that can be accessed without authentication
* should match the files in client/public
*/
const publicAssets = new Set([
'/favicon.ico',
'/manifest.json',
'/ontime-logo.png',
'/robots.txt',
'/site.webmanifest',
]);
export const loginRouter = express.Router();
// serve static files at root
loginRouter.use('/', express.static(srcFiles.login));
// verify password and set cookies + redirect appropriately
loginRouter.post('/', (req, res) => {
res.clearCookie('token');
const { password: reqPassword, redirect } = req.body;
if (hashPassword(reqPassword) === hashedPassword) {
setSessionCookie(res, hashedPassword);
res.redirect(redirect || '/');
return;
}
res.status(401).send('Unauthorized');
});
/**
* Express middleware to authenticate requests
* @param {string} prefix - Prefix is used for the client hashes in Ontime Cloud
*/
export function makeAuthenticateMiddleware(prefix: string) {
// we dont need to initialise the authenticate middleware if there is no password
if (!hasPassword) {
return { authenticate: noopMiddleware, authenticateAndRedirect: noopMiddleware };
}
function authenticate(req: Request, res: Response, next: NextFunction) {
const token = req.query.token || req.cookies?.token;
if (token && token === hashedPassword) {
return next();
}
res.status(401).send('Unauthorized');
}
function authenticateAndRedirect(req: Request, res: Response, next: NextFunction) {
// Allow access to specific public assets without authentication
if (publicAssets.has(req.originalUrl)) {
return next();
}
// we shouldnt be here in the login route
if (req.originalUrl.startsWith('/login')) {
return next();
}
// we expect the token to be in the cookies
if (req.cookies?.token === hashedPassword) {
return next();
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (req.query.token === hashedPassword) {
setSessionCookie(res, hashedPassword);
return next();
}
res.redirect(`${prefix}/login?redirect=${req.originalUrl}`);
}
return { authenticate, authenticateAndRedirect };
}
/**
* Middleware to authenticate a WebSocket connection with a token in the cookie
*/
export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (error?: Error) => void) {
if (!hasPassword) {
return next();
}
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token === hashedPassword) {
return next();
}
}
// check if token is in the params
const url = new URL(req.url || '', `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
logger.warning(LogOrigin.Client, 'Unauthorized WebSocket connection attempt');
return next(new Error('Unauthorized'));
}
function setSessionCookie(res: Response, token: string) {
res.cookie('token', token, {
httpOnly: false, // allow websocket to access cookie
secure: true,
path: '/', // allow cookie to be accessed from any path
sameSite: 'strict',
});
}
+5
View File
@@ -0,0 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
export function noopMiddleware(_req: Request, _res: Response, next: NextFunction) {
next();
}
+28
View File
@@ -21,6 +21,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -47,6 +49,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -73,6 +77,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -99,6 +105,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -125,6 +133,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -156,6 +166,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -182,6 +194,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -208,6 +222,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -234,6 +250,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -260,6 +278,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -291,6 +311,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -317,6 +339,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -343,6 +367,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -369,6 +395,8 @@ export const demoDb: DatabaseModel = {
colour: '',
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 500000,
timeDanger: 100000,
custom: {
@@ -25,6 +25,8 @@ export const event: Omit<OntimeEvent, 'id' | 'cue'> = {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
custom: {},
@@ -1,23 +1,7 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
import { loadRoll } from '../rollUtils.js';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
};
function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
import { prepareTimedEvents, makeOntimeEvent } from '../rundown-service/__mocks__/rundown.mocks.js';
describe('loadRoll()', () => {
const eventlist = [
@@ -8,6 +8,7 @@ import { shouldCrashDev } from '../../utils/development.js';
interface AppState {
lastLoadedProject?: string;
showWelcomeDialog?: boolean;
}
const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -34,3 +35,19 @@ export async function setLastLoadedProject(filename: string): Promise<void> {
config.data.lastLoadedProject = filename;
await config.write();
}
export async function getShowWelcomeDialog(): Promise<boolean> {
// in test environment, we do not want the dialog
if (isTest) return false;
await config.read();
return config.data.showWelcomeDialog ?? true; // default to true
}
export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
if (isTest) return;
config.data.showWelcomeDialog = show;
await config.write();
return show;
}
@@ -0,0 +1,31 @@
import { SupportedEvent, OntimeEvent, OntimeDelay } from 'ontime-types';
const baseEvent = {
type: SupportedEvent.Event,
skip: false,
revision: 1,
};
/**
* Utility to create a Ontime event
*/
export function makeOntimeEvent(patch: Partial<OntimeEvent>): OntimeEvent {
return {
...baseEvent,
...patch,
} as OntimeEvent;
}
/**
* Utility to create a delay event
*/
export function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration };
}
/**
* Utility to generate a rundown of OntimeEvents form partial objects
*/
export function prepareTimedEvents(events: Partial<OntimeEvent>[]): OntimeEvent[] {
return events.map(makeOntimeEvent);
}
@@ -1,20 +1,8 @@
import { OntimeBlock, OntimeDelay, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { apply } from '../delayUtils.js';
import { OntimeBlock, OntimeEvent, OntimeRundown, SupportedEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';
/**
* Small utility to fill in the necessary data for the test
*/
function makeOntimeEvent(event: Partial<OntimeEvent>): OntimeEvent {
return { ...event, type: SupportedEvent.Event, revision: 1 } as OntimeEvent;
}
/**
* Small utility to make a delay event
*/
function makeOntimeDelay(duration: number): OntimeDelay {
return { id: 'delay', type: SupportedEvent.Delay, duration } as OntimeDelay;
}
import { apply } from '../delayUtils.js';
import { makeOntimeDelay, makeOntimeEvent } from '../__mocks__/rundown.mocks.js';
describe('apply()', () => {
it('applies a positive delay to the rundown', () => {
@@ -150,11 +138,11 @@ describe('apply()', () => {
makeOntimeDelay(100),
makeOntimeEvent({ id: '1', timeStart: 0, timeEnd: 100, duration: 100 }),
// gap 50
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50 }),
makeOntimeEvent({ id: '2', timeStart: 150, timeEnd: 200, duration: 50, gap: 50 }),
// gap 0
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50, gap: 0 }),
// gap 50
makeOntimeEvent({ id: '3', timeStart: 200, timeEnd: 250, duration: 50 }),
// gap 50
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50 }),
makeOntimeEvent({ id: '4', timeStart: 300, timeEnd: 350, duration: 50, gap: 50 }),
// linked
makeOntimeEvent({ id: '5', timeStart: 350, timeEnd: 400, duration: 50, linkStart: '4' }),
];
@@ -165,7 +153,7 @@ describe('apply()', () => {
// gap 50 (100 - 50)
{ id: '2', timeStart: 150 + 50, timeEnd: 200 + 50, duration: 50, revision: 2 },
// gap 50 (50 - 50)
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2 },
{ id: '3', timeStart: 200 + 50, timeEnd: 250 + 50, duration: 50, revision: 2, gap: 0 },
// gap (delay is 0)
{ id: '4', timeStart: 300, timeEnd: 350, duration: 50, revision: 1 },
// linked
@@ -178,6 +166,8 @@ describe('apply()', () => {
makeOntimeDelay(2 * MILLIS_PER_HOUR),
makeOntimeEvent({
id: '1',
gap: 0,
dayOffset: 0,
timeStart: 46800000, // 13:00:00
timeEnd: 50400000, // 14:00:00
duration: MILLIS_PER_HOUR,
@@ -185,6 +175,8 @@ describe('apply()', () => {
// gap 1h
makeOntimeEvent({
id: '2',
gap: 1 * MILLIS_PER_HOUR,
dayOffset: 0,
timeStart: 54000000, // 15:00:00
timeEnd: 57600000, // 16:00:00
duration: MILLIS_PER_HOUR,
@@ -577,6 +577,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
@@ -605,6 +607,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -633,6 +637,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -661,6 +667,8 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
delay: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -701,6 +709,8 @@ describe('getDelayAt()', () => {
timeDanger: 60000,
id: '659e1',
delay: 0,
dayOffset: 0,
gap: 0,
cue: '1',
custom: {},
},
@@ -725,6 +735,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -753,6 +765,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -781,6 +795,8 @@ describe('getDelayAt()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -835,6 +851,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '659e1',
@@ -863,6 +881,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '1c48f',
@@ -891,6 +911,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: 'd48c2',
@@ -919,6 +941,8 @@ describe('calculateRuntimeDelaysFrom()', () => {
colour: '',
type: SupportedEvent.Event,
revision: 0,
dayOffset: 0,
gap: 0,
timeWarning: 120000,
timeDanger: 60000,
id: '2f185',
@@ -9,12 +9,14 @@ import {
} from 'ontime-types';
import {
addToCustomAssignment,
calculateDayOffset,
getLink,
handleCustomField,
handleLink,
hasChanges,
isDataStale,
} from '../rundownCacheUtils.js';
import { MILLIS_PER_HOUR } from 'ontime-utils';
describe('getLink()', () => {
it('should return null if there is no link', () => {
@@ -247,3 +249,61 @@ describe('hasChanges()', () => {
expect(hasChanges(existing, newEvent)).toBe(false);
});
});
describe('calculateDayOffset', () => {
it('returns 0 if there is no previous event', () => {
expect(calculateDayOffset({ timeStart: 0 })).toBe(0);
});
it('returns 0 if the previous event duration is 0', () => {
expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
});
it('returns 0 if event starts after previous', () => {
expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
});
it('returns 1 if event starts before previous', () => {
expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('returns 1 if event starts at the same time as one before', () => {
expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
});
it('should account for an event that crossed midnight and there is a overlap', () => {
expect(
calculateDayOffset(
{ timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight and there is a gap', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
),
).toBe(1);
});
it('should account for an event that crossed midnight with no overlaps or gaps', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
),
).toBe(1);
});
it('should account for an event that finishes exactly at midnight', () => {
expect(
calculateDayOffset(
{ timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
{ timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
),
).toBe(1);
});
});
@@ -1,5 +1,5 @@
import { OntimeRundown, isOntimeDelay, isOntimeBlock, isOntimeEvent, OntimeEvent } from 'ontime-types';
import { getTimeFromPrevious, deleteAtIndex } from 'ontime-utils';
import { deleteAtIndex } from 'ontime-utils';
/**
* Calculates all delays in a given rundown
@@ -133,21 +133,14 @@ export function apply(eventId: string, rundown: OntimeRundown): OntimeRundown {
// if the event is not linked, we try and maintain gaps
if (lastEntry !== null) {
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry.timeStart,
lastEntry.timeEnd,
lastEntry.duration,
);
// when applying negative delays, we need to unlink the event
// if the previous event was fully consumed by the delay
if (currentEntry.linkStart && delayValue < 0 && lastEntry.timeStart + delayValue < 0) {
shouldUnlink = true;
}
if (timeFromPrevious > 0) {
delayValue = Math.max(delayValue - timeFromPrevious, 0);
if (currentEntry.gap > 0) {
delayValue = Math.max(delayValue - currentEntry.gap, 0);
}
if (delayValue === 0) {
@@ -24,7 +24,7 @@ import {
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { createPatch } from '../../utils/parser.js';
import { apply } from './delayUtils.js';
import { handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
import { calculateDayOffset, handleCustomField, handleLink, hasChanges, isDataStale } from './rundownCacheUtils.js';
type EventID = string;
type NormalisedRundown = Record<EventID, OntimeRundownEntry>;
@@ -55,6 +55,7 @@ function setIsStale() {
let totalDelay = 0;
let totalDuration = 0;
let totalDays = 0;
let firstStart: MaybeNumber = null;
let lastEnd: MaybeNumber = null;
@@ -107,6 +108,7 @@ export function generate(
firstStart = null;
lastEnd = null;
totalDuration = 0;
totalDays = 0;
totalDelay = 0;
let lastEntry: PlayableEvent | null = null;
@@ -117,6 +119,7 @@ export function generate(
if (isOntimeEvent(currentEntry)) {
currentEntry.delay = 0;
currentEntry.gap = 0;
// 1. handle links - mutates updatedEvent
handleLink(i, initialRundown, currentEntry, links);
@@ -124,6 +127,9 @@ export function generate(
// 2. handle custom fields - mutates updatedEvent
handleCustomField(customFields, customFieldChangelog, currentEntry, assignedCustomFields);
totalDays += calculateDayOffset(currentEntry, lastEntry);
currentEntry.dayOffset = totalDays;
// update rundown metadata, it only concerns playable events
if (isPlayableEvent(currentEntry)) {
// fist start is always the first event
@@ -131,36 +137,31 @@ export function generate(
firstStart = currentEntry.timeStart;
}
const timeFromPrevious: number = getTimeFromPrevious(
currentEntry.timeStart,
lastEntry?.timeStart,
lastEntry?.timeEnd,
lastEntry?.duration,
);
currentEntry.gap = getTimeFromPrevious(currentEntry, lastEntry);
if (timeFromPrevious === 0) {
if (currentEntry.gap === 0) {
// event starts on previous finish, we add its duration
totalDuration += currentEntry.duration;
} else if (timeFromPrevious > 0) {
} else if (currentEntry.gap > 0) {
// event has a gap, we add the gap and the duration
totalDuration += timeFromPrevious + currentEntry.duration;
} else if (timeFromPrevious < 0) {
totalDuration += currentEntry.gap + currentEntry.duration;
} else if (currentEntry.gap < 0) {
// there is an overlap, we remove the overlap from the duration
// ensuring that the sum is not negative (ie: fully overlapped events)
// NOTE: we add the gap since it is a negative number
totalDuration += Math.max(currentEntry.duration + timeFromPrevious, 0);
totalDuration += Math.max(currentEntry.duration + currentEntry.gap, 0);
}
// remove eventual gaps from the accumulated delay
// we only affect positive delays (time forwards)
if (totalDelay > 0 && timeFromPrevious > 0) {
totalDelay = Math.max(totalDelay - timeFromPrevious, 0);
if (totalDelay > 0 && currentEntry.gap > 0) {
totalDelay = Math.max(totalDelay - currentEntry.gap, 0);
}
// current event delay is the current accumulated delay
currentEntry.delay = totalDelay;
// lastEntry is the event with the latest end time
if (isNewLatest(currentEntry.timeStart, currentEntry.timeEnd, lastEntry?.timeStart, lastEntry?.timeEnd)) {
if (isNewLatest(currentEntry, lastEntry)) {
lastEntry = currentEntry;
}
}
@@ -7,7 +7,7 @@ import {
OntimeRundownEntry,
OntimeBaseEvent,
} from 'ontime-types';
import { getLinkedTimes } from 'ontime-utils';
import { dayInMs, getLinkedTimes } from 'ontime-utils';
/**
* Get linked event
@@ -147,3 +147,36 @@ export function hasChanges<T extends OntimeBaseEvent>(existingEvent: T, newEvent
(key) => !Object.hasOwn(existingEvent, key) || existingEvent[key as keyof T] !== newEvent[key as keyof T],
);
}
/**
* Utility for calculating if the current events should have a day offset
* @param current the current event under test
* @param previous the previous event
* @returns 0 or 1 for easy accumulation with the total days
*/
export function calculateDayOffset(
current: Pick<OntimeEvent, 'timeStart'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration'>,
) {
// if there is no previous there can't be a day offset
if (!previous) {
return 0;
}
// if the previous events duration is zero it will push the current event to next day
if (previous.duration === 0) {
return 0;
}
// if the previous event crossed midnight then the current event is in the next day
if (previous.timeStart + previous.duration >= dayInMs) {
return 1;
}
// if the current events starts at the same time or before the previous event then it is the next day
if (current.timeStart <= previous.timeStart) {
return 1;
}
return 0;
}
@@ -37,6 +37,8 @@ describe('cellRequestFromEvent()', () => {
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -84,6 +86,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -132,6 +136,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -178,6 +184,8 @@ describe('cellRequestFromEvent()', () => {
skip: false,
colour: 'red',
delay: 0,
gap: 0,
dayOffset: 0,
revision: 0,
id: '1358',
timeWarning: 0,
@@ -212,6 +220,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
@@ -246,6 +256,8 @@ describe('cellRequestFromEvent()', () => {
colour: 'red',
revision: 0,
delay: 0,
gap: 0,
dayOffset: 0,
id: '1358',
timeWarning: 0,
timeDanger: 0,
+2
View File
@@ -87,6 +87,8 @@ export const srcFiles = {
userReadme: join(srcDir.root, config.user, 'README.md'),
/** Path to bundled CSS readme */
cssReadme: join(srcDir.root, config.user, config.styles.directory, 'README.md'),
/** Path to login */
login: join(srcDir.root, 'html/login.html'),
};
/**
@@ -0,0 +1,46 @@
import { TimerPhase, Playback } from 'ontime-types';
import { deepmerge } from 'ontime-utils';
import { RuntimeState } from '../runtimeState.js';
const baseState: RuntimeState = {
clock: 0,
currentBlock: {
block: null,
startedAt: null,
},
eventNow: null,
publicEventNow: null,
eventNext: null,
publicEventNext: null,
runtime: {
selectedEventIndex: null,
numEvents: 0,
offset: 0,
plannedStart: 0,
plannedEnd: 0,
actualStart: null,
expectedEnd: null,
},
timer: {
addedTime: 0,
current: null,
duration: null,
elapsed: null,
expectedFinish: null,
finishedAt: null,
phase: TimerPhase.None,
playback: Playback.Stop,
secondaryTimer: null,
startedAt: null,
},
_timer: {
forceFinish: null,
totalDelay: 0,
pausedAt: null,
secondaryTarget: null,
},
};
export function makeRuntimeStateData(patch?: Partial<RuntimeState>) {
return deepmerge(baseState, patch);
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash } from 'node:crypto';
/**
* Creates a hash of the password that is URL safe
* @link https://stackoverflow.com/questions/17639645/websafe-encoding-of-hashed-string-in-nodejs
*/
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('base64url');
}
+3 -1
View File
@@ -384,7 +384,9 @@ export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<Onti
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
note: makeString(patchEvent.note, originalEvent.note),
colour: makeString(patchEvent.colour, originalEvent.colour),
delay: 0, //delay is always regenerated by the cache
delay: 0, // is always regenerated by the cache
dayOffset: 0, // is always regenerated by the cache
gap: 0, // is always regenerated by the cache
// short circuit empty string
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
revision: originalEvent.revision,
+16 -2
View File
@@ -1,5 +1,4 @@
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, parseUserTime } from 'ontime-utils';
import { isISO8601 } from '../../../../packages/utils/src/date-utils/isTimeString.js';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, MILLIS_PER_SECOND, pad, parseUserTime, isISO8601 } from 'ontime-utils';
export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
@@ -46,3 +45,18 @@ export function parseExcelDate(excelDate: unknown): number {
return 0;
}
export function getTimezoneLabel(date: Date): string {
const tz = date.getTimezoneOffset();
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone;
// timezone offset is inverted
const sign = tz < 0 ? '+' : '-';
const abs = Math.abs(tz);
// convert minutes to hours
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
return `GMT ${sign}${pad(hours)}:${pad(minutes)} ${tzName}`;
}
+419 -295
View File
@@ -1,307 +1,420 @@
{
"rundown": [
{
"title": "Albania",
"note": "SF1.01",
"endAction": "none",
"timerType": "count-down",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "32d31",
"cue": "SF1.01",
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
},
{
"title": "Latvia",
"note": "SF1.02",
"endAction": "none",
"timerType": "count-down",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "21cd2",
"cue": "SF1.02",
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"title": "Lithuania",
"note": "SF1.03",
"endAction": "none",
"timerType": "count-down",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "0b371",
"cue": "SF1.03",
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"title": "Switzerland",
"note": "SF1.04",
"endAction": "none",
"timerType": "count-down",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "3cd28",
"cue": "SF1.04",
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"title": "Slovenia",
"note": "SF1.05",
"endAction": "none",
"timerType": "count-down",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "e457f",
"cue": "SF1.05",
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"title": "Ukraine",
"note": "SF1.06",
"endAction": "none",
"timerType": "count-down",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "1c420",
"cue": "SF1.06",
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"title": "Bulgaria",
"note": "SF1.07",
"endAction": "none",
"timerType": "count-down",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "b7737",
"cue": "SF1.07",
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"title": "Netherlands",
"note": "SF1.08",
"endAction": "none",
"timerType": "count-down",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3a80",
"cue": "SF1.08",
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"title": "Moldova",
"note": "SF1.09",
"endAction": "none",
"timerType": "count-down",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "8276c",
"cue": "SF1.09",
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"title": "Portugal",
"note": "SF1.10",
"endAction": "none",
"timerType": "count-down",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "2340b",
"cue": "SF1.10",
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"title": "Croatia",
"note": "SF1.11",
"endAction": "none",
"timerType": "count-down",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "503c4",
"cue": "SF1.11",
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"title": "Denmark",
"note": "SF1.12",
"endAction": "none",
"timerType": "count-down",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "5e965",
"cue": "SF1.12",
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"title": "Austria",
"note": "SF1.13",
"endAction": "none",
"timerType": "count-down",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "bab4a",
"cue": "SF1.13",
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"title": "Greece",
"note": "SF1.14",
"endAction": "none",
"timerType": "count-down",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"isPublic": true,
"skip": false,
"colour": "",
"type": "event",
"revision": 0,
"id": "d3eb1",
"cue": "SF1.14",
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
{
"id": "32d31",
"type": "event",
"title": "Albania",
"timeStart": 36000000,
"timeEnd": 37200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.01",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.01",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sekret",
"artist": "Ronela Hajati"
}
],
},
{
"id": "21cd2",
"type": "event",
"title": "Latvia",
"timeStart": 37500000,
"timeEnd": 38700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.02",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.02",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Eat Your Salad",
"artist": "Citi Zeni"
}
},
{
"id": "0b371",
"type": "event",
"title": "Lithuania",
"timeStart": 39000000,
"timeEnd": 40200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.03",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.03",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Sentimentai",
"artist": "Monika Liu"
}
},
{
"id": "3cd28",
"type": "event",
"title": "Switzerland",
"timeStart": 40500000,
"timeEnd": 41700000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.04",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.04",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Boys Do Cry",
"artist": "Marius Bear"
}
},
{
"id": "e457f",
"type": "event",
"title": "Slovenia",
"timeStart": 42000000,
"timeEnd": 43200000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.05",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.05",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Disko",
"artist": "LPS"
}
},
{
"title": "Lunch break",
"type": "block",
"id": "01e85"
},
{
"id": "1c420",
"type": "event",
"title": "Ukraine",
"timeStart": 47100000,
"timeEnd": 48300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.06",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.06",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Stefania",
"artist": "Kalush Orchestra"
}
},
{
"id": "b7737",
"type": "event",
"title": "Bulgaria",
"timeStart": 48600000,
"timeEnd": 49800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.07",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.07",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Intention",
"artist": "Intelligent Music Project"
}
},
{
"id": "d3a80",
"type": "event",
"title": "Netherlands",
"timeStart": 50100000,
"timeEnd": 51300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.08",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.08",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "De Diepte",
"artist": "S10"
}
},
{
"id": "8276c",
"type": "event",
"title": "Moldova",
"timeStart": 51600000,
"timeEnd": 52800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.09",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.09",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Trenuletul",
"artist": "Zdob si Zdub"
}
},
{
"id": "2340b",
"type": "event",
"title": "Portugal",
"timeStart": 53100000,
"timeEnd": 54300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.10",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.10",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Saudade Saudade",
"artist": "Maro"
}
},
{
"title": "Afternoon break",
"type": "block",
"id": "cb90b"
},
{
"id": "503c4",
"type": "event",
"title": "Croatia",
"timeStart": 56100000,
"timeEnd": 57300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.11",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.11",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Guilty Pleasure",
"artist": "Mia Dimsic"
}
},
{
"id": "5e965",
"type": "event",
"title": "Denmark",
"timeStart": 57600000,
"timeEnd": 58800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.12",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.12",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "The Show",
"artist": "Reddi"
}
},
{
"id": "bab4a",
"type": "event",
"title": "Austria",
"timeStart": 59100000,
"timeEnd": 60300000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.13",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.13",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Halo",
"artist": "LUM!X & Pia Maria"
}
},
{
"id": "d3eb1",
"type": "event",
"title": "Greece",
"timeStart": 60600000,
"timeEnd": 61800000,
"duration": 1200000,
"timeStrategy": "lock-duration",
"linkStart": null,
"endAction": "none",
"timerType": "count-down",
"countToEnd": false,
"isPublic": true,
"skip": false,
"note": "SF1.14",
"colour": "",
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.14",
"revision": 0,
"timeWarning": 120000,
"timeDanger": 60000,
"custom": {
"song": "Die Together",
"artist": "Amanda Tenfjord"
}
}
],
"project": {
"title": "Eurovision Song Contest",
"description": "Turin 2022",
"publicUrl": "www.getontime.no",
"publicInfo": "Rehearsal Schedule - Turin 2022",
"backstageUrl": "www.github.com/cpvalente/ontime",
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal"
"backstageInfo": "Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal",
"projectLogo": null
},
"settings": {
"app": "ontime",
"version": "2.0.0",
"version": "3.10.2",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
@@ -312,10 +425,9 @@
"overrideStyles": false,
"normalColor": "#ffffffcc",
"warningColor": "#FFAB33",
"warningThreshold": 120000,
"dangerColor": "#ED3333",
"dangerThreshold": 60000,
"endMessage": ""
"endMessage": "",
"freezeEnd": false
},
"urlPresets": [
{
@@ -335,5 +447,17 @@
"http": {
"enabledOut": true,
"subscriptions": []
},
"customFields": {
"song": {
"type": "string",
"colour": "",
"label": "song"
},
"artist": {
"type": "string",
"colour": "",
"label": "artist"
}
}
}
+57
View File
@@ -0,0 +1,57 @@
# Automations
The automation feature's purpose is to integrate ontime into users' workflows.
Ontime has a large amount of production information, which users need considerable effort to maintain. We want to allow tools so that:
- allow distribution of Ontime's and other production data
- allow surfacing Ontime events
- allow synchronizing with other tools
## Previous
Previous iterations imposed limitations on the number of integrations and target devices to evaluate performance concerns.
The feature was not as used as we had hoped, and users often escalated the integration to tools like Companion.
I believe this to be in part from a lack of clarity on the feature and the limitations imposed
- Lack of explicit filtering logic made the process hard to reason
- Users met limitations on target devices earlier than expected. We would want users to meet these limitations only when the project grew over a size where a show controller would be needed
- Building the template strings was complex and poorly documented
## Overview
- The user should be able to create as many automations as they want
- Automations are triggered by lifecycle events
- Each automation should go through a user-defined filtering process
- Each automation should be able to target multiple devices and multiple protocols
- We leverage HTTP / OSC as the main protocols ~~and add support for triggering Companion button presses~~
- To allow easier debugging and "learn" workflows, users should be able to test a message before saving the automation
- We should have inline documentation for the template strings
> ⚠️ **Companion module** \
> We have opted for removing the requirement for companion module integration. \
> We did not see as much friction that could be solved with a custom integration as initially thought. Triggering a button press in companion is done trivially over either OSC or HTTP and companion users are familiar with the process.
## Implementation details
- To simplify the usage of template strings, we will generate a list of template strings at runtime from the user project file
- To allow easier implementation and extensions, we want to keep the automations separate from triggers
```ts
type Automation = {
id: AutomationId;
name: string;
filter: Filter[];
output: Output[];
};
type Trigger = {
id: string;
event: TimerLifecycle;
automations: AutomationId[];
};
```
### Extension
- Users have expressed a desire to have automation triggered by the lifecycle of a specific event. By keeping the trigger separate from the automation, we will allow this to be implemented in the future.
+7 -8
View File
@@ -8,9 +8,6 @@ const fileToDownload = 'e2e/tests/fixtures/tmp/test-db.json';
test('project file upload', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
@@ -41,9 +38,6 @@ test('project file upload', async ({ page }) => {
test('project file download', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
// close the welcome modal if it is open
await page.keyboard.down('Escape');
await page.getByRole('button', { name: 'toggle settings' }).click();
await page.getByRole('button', { name: 'Project', exact: true }).click();
@@ -61,8 +55,13 @@ test('project file download', async ({ page }) => {
// Wait for the download process to complete and save the downloaded file somewhere.
await download.saveAs(fileToDownload);
await expect(download.failure()).toMatchObject({});
expect(download.failure()).toMatchObject({});
const original = JSON.parse(await readFile(fileToUpload, { encoding: 'utf-8' }));
const fromServer = JSON.parse(await readFile(fileToDownload, { encoding: 'utf-8' }));
await expect(original).toEqual(fromServer);
// when a file is parsed, the server will write the version number to the project file
original.settings.version = 'not-important';
fromServer.settings.version = 'not-important';
expect(original).toMatchObject(fromServer);
});
@@ -0,0 +1,38 @@
import { expect, test } from '@playwright/test';
test('show warning when event crosses midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
await page.getByTestId('entry-2').getByTestId('lock__end').getByRole('img').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('23h');
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
await page.getByTestId('entry-3').getByTestId('time-input-duration').click();
await page.getByTestId('entry-3').getByTestId('time-input-duration').fill('2h');
await page.getByTestId('entry-3').getByTestId('time-input-duration').press('Enter');
await expect(page.getByTestId('entry-3').getByTestId('event-warning')).toBeVisible();
});
test('show warning when event starts next day midnight', async ({ page }) => {
await page.goto('http://localhost:4001/editor');
await page.getByRole('button', { name: 'Clear rundown' }).click();
await page.getByRole('button', { name: 'Delete all' }).click();
await page.getByRole('button', { name: 'Create Event' }).click();
await page.getByRole('button', { name: 'Event' }).nth(4).click();
await page.getByTestId('entry-2').getByText('E').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').click();
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').fill('0');
await page.getByTestId('entry-2').getByTestId('time-input-timeEnd').press('Enter');
await page.getByRole('button', { name: 'Event', exact: true }).nth(2).click();
await expect(page.getByText('(next day)')).toBeVisible();
});
+42 -14
View File
@@ -16,7 +16,9 @@
"skip": false,
"note": "SF1.01",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.01",
"revision": 0,
"timeWarning": 120000,
@@ -42,7 +44,9 @@
"skip": false,
"note": "SF1.02",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.02",
"revision": 0,
"timeWarning": 120000,
@@ -68,7 +72,9 @@
"skip": false,
"note": "SF1.03",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.03",
"revision": 0,
"timeWarning": 120000,
@@ -94,7 +100,9 @@
"skip": false,
"note": "SF1.04",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.04",
"revision": 0,
"timeWarning": 120000,
@@ -120,7 +128,9 @@
"skip": false,
"note": "SF1.05",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.05",
"revision": 0,
"timeWarning": 120000,
@@ -151,7 +161,9 @@
"skip": false,
"note": "SF1.06",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.06",
"revision": 0,
"timeWarning": 120000,
@@ -177,7 +189,9 @@
"skip": false,
"note": "SF1.07",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.07",
"revision": 0,
"timeWarning": 120000,
@@ -203,7 +217,9 @@
"skip": false,
"note": "SF1.08",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.08",
"revision": 0,
"timeWarning": 120000,
@@ -229,7 +245,9 @@
"skip": false,
"note": "SF1.09",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.09",
"revision": 0,
"timeWarning": 120000,
@@ -255,7 +273,9 @@
"skip": false,
"note": "SF1.10",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.10",
"revision": 0,
"timeWarning": 120000,
@@ -286,7 +306,9 @@
"skip": false,
"note": "SF1.11",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.11",
"revision": 0,
"timeWarning": 120000,
@@ -312,7 +334,9 @@
"skip": false,
"note": "SF1.12",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.12",
"revision": 0,
"timeWarning": 120000,
@@ -338,7 +362,9 @@
"skip": false,
"note": "SF1.13",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.13",
"revision": 0,
"timeWarning": 120000,
@@ -364,7 +390,9 @@
"skip": false,
"note": "SF1.14",
"colour": "",
"delay": 0,
"delay": 0,
"dayOffset": 0,
"gap": 0,
"cue": "SF1.14",
"revision": 0,
"timeWarning": 120000,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "3.10.3",
"version": "3.10.4",
"description": "Time keeping for live events",
"keywords": [
"ontime",
@@ -38,7 +38,7 @@
"cleanup": "rm -rf node_modules && rm -rf **/node_modules && rm -rf **/**/node_modules"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@playwright/test": "^1.49.1",
"@types/node": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
@@ -15,7 +15,7 @@ export interface SessionStats {
lastRequest: MaybeString;
projectName: string;
playback: Playback;
timezone: number;
timezone: string;
}
export interface GetInfo {
@@ -39,6 +39,8 @@ export type OntimeEvent = OntimeBaseEvent & {
colour: string;
revision: number;
delay: number; // calculated at runtime
dayOffset: number; // calculated at runtime
gap: number; // calculated at runtime
timeWarning: number;
timeDanger: number;
custom: EventCustomFields;
+5 -2
View File
@@ -44,10 +44,11 @@ export {
millisToSeconds,
secondsInMillis,
} from './src/date-utils/conversionUtils.js';
export { isTimeString } from './src/date-utils/isTimeString.js';
export { isISO8601, isTimeString } from './src/date-utils/isTimeString.js';
export {
formatFromMillis,
millisToString,
pad,
removeLeadingZero,
removeSeconds,
removeTrailingZero,
@@ -63,7 +64,9 @@ export { customFieldLabelToKey } from './src/customField-utils/customFieldLabelT
export { deepmerge } from './src/externals/deepmerge.js';
// array utils
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/array-utils/arrayUtils.js';
export { deleteAtIndex, insertAtIndex, reorderArray } from './src/common/arrayUtils.js';
// object utils
export { getPropertyFromPath } from './src/common/objectUtils.js';
// generic utilities
export { getErrorMessage } from './src/generic/generic.js';
+18
View File
@@ -0,0 +1,18 @@
/**
* Extracts a value from a nested object using a dot-separated path
*/
export function getPropertyFromPath<T extends object>(path: string, obj: T): any | undefined {
const keys = path.split('.');
let result: any = obj;
// iterate through variable parts, and look for the property in the given object
for (const key of keys) {
if (result && typeof result === 'object' && key in result) {
result = result[key];
} else {
return undefined;
}
}
return result;
}
@@ -0,0 +1,17 @@
import { getPropertyFromPath } from './objectUtils';
describe('getNestedFromTemplate', () => {
it('should return the value of a nested property', () => {
expect(getPropertyFromPath('a', { a: 1 })).toBe(1);
expect(getPropertyFromPath('a.b', { a: { b: 1 } })).toBe(1);
expect(getPropertyFromPath('a.b.c', { a: { b: { c: 1 } } })).toBe(1);
});
it('should guard against values that do not exist', () => {
expect(getPropertyFromPath('c', {})).toBe(undefined);
expect(getPropertyFromPath('c', { a: 1 })).toBe(undefined);
expect(getPropertyFromPath('a.c', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('c.a', { a: { b: 1 } })).toBeUndefined();
expect(getPropertyFromPath('a.b.b', { a: { b: { c: 1 } } })).toBeUndefined();
});
});
@@ -2,66 +2,51 @@ import { checkIsNextDay } from './checkIsNextDay';
import { MILLIS_PER_HOUR } from './conversionUtils';
describe('checkIsNextDay', () => {
it('returns false if the previous event duration is 0', () => {
const previousStart = 0;
const previousDuration = 0;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
it('returns false if there is no previous event', () => {
const current = { timeStart: 0, dayOffset: 0 };
const previous = undefined;
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns false if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 11;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 11, dayOffset: 0 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('returns true if event starts after one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 9;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 9, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('returns true if event starts at the same time as one before', () => {
const previousStart = 10;
const previousDuration = 2;
const timeStart = 10;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 10, dayOffset: 1 };
const previous = { timeStart: 10, duration: 2, dayOffset: 0 };
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
it('should account for an event that crossed midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 1 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight and there is a gap', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
const previousDuration = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 01:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that crossed midnight with no overlaps', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR; // event finished at 02:00:00
const timeStart = 19 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeFalsy();
const current = { timeStart: 19 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 02:00:00
expect(checkIsNextDay(current, previous)).toBeFalsy();
});
it('should account for an event that finishes exactly at midnight', () => {
const previousStart = 23 * MILLIS_PER_HOUR;
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 2 * MILLIS_PER_HOUR;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
});
it('should account for normalised start over multiple days', () => {
const previousStart = 90000000; // 25:00:00
const previousDuration = 1 * MILLIS_PER_HOUR;
const timeStart = 0;
expect(checkIsNextDay(previousStart, timeStart, previousDuration)).toBeTruthy();
const current = { timeStart: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 1 * MILLIS_PER_HOUR, dayOffset: 0 }; // event finished at 24:00:00
expect(checkIsNextDay(current, previous)).toBeTruthy();
});
});
+16 -30
View File
@@ -1,41 +1,27 @@
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility function checks whether a given event is the day after from its predecessor
* We consider an event to be the day after, if it begins before the start of the previous
* @example day after
* 09:00 - 10:00
* 08:00 - 10:30
* @example day after
* 23:00 - 00:00
* 02:00 - 03:00
* @example same day
* 09:00 - 10:00
* 09:30 - 10:30
* @example same day, but previous crosses midnight
* 23:00 - 01:00
* 02:00 - 03:00
* @example same day, but previous crosses midnight (with overlap)
* 22:00 - 02:00
* 01:00 - 03:00
*/
export function checkIsNextDay(previousStart: number, timeStart: number, previousDuration: number): boolean {
if (previousDuration === 0) {
export function checkIsNextDay(
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): boolean {
if (!previous) {
return false;
}
const cappedStart = previousStart % dayInMs;
if (timeStart <= cappedStart) {
const normalisedPreviousEnd = cappedStart + previousDuration;
if (normalisedPreviousEnd === dayInMs) {
return true;
}
// handle exception for an event that finishes exactly at midnight
if (normalisedPreviousEnd > dayInMs) {
return false;
}
return true;
// if the day offsets are the same it can't be the next day
if (current.dayOffset <= previous.dayOffset) {
return false;
}
return false;
// if the previous event crossed midnight then the current is the same day
if (previous.timeStart + previous.duration > dayInMs) {
return false;
}
return true;
}
@@ -1,64 +1,58 @@
import { dayInMs, MILLIS_PER_HOUR } from './conversionUtils';
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
import { getTimeFromPrevious } from './getTimeFromPrevious';
describe('getTimeFromPrevious', () => {
it('returns the time elapsed (gap or overlap) from the previous', () => {
const previousStart = 69600000; // 19:20
const previousEnd = 71700000; // 19:55
const previousDuration = 2100000; // 35 minutes
const currentStart = 75600000; // 21:00
const expected = 75600000 - 71700000; // current start - previousEnd
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 21 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 19 * MILLIS_PER_HOUR + 20 * MILLIS_PER_MINUTE, duration: 35 * MILLIS_PER_MINUTE, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for partially overlapping events', () => {
const previousStart = 10;
const previousEnd = 12;
const previousDuration = 2;
const currentStart = 11;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -1;
expect(getTimeFromPrevious({ timeStart: 11, dayOffset: 0 }, { timeStart: 10, duration: 2, dayOffset: 0 })).toBe(
expected,
);
});
it('accounts for events that are fully contained', () => {
const previousStart = 8;
const previousEnd = 16;
const previousDuration = 8;
const currentStart = 10;
const expected = -(previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = -6;
expect(getTimeFromPrevious({ timeStart: 10, dayOffset: 0 }, { timeStart: 8, duration: 8, dayOffset: 0 })).toBe(
expected,
);
});
it('fully overlapping events are the next day', () => {
const previousStart = 10 * MILLIS_PER_HOUR;
const previousEnd = 12 * MILLIS_PER_HOUR;
const previousDuration = previousEnd - previousStart;
const currentStart = 10 * MILLIS_PER_HOUR;
const expected = dayInMs - previousDuration;
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
const expected = dayInMs - 2 * MILLIS_PER_HOUR;
expect(
getTimeFromPrevious(
{ timeStart: 10 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 10 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that are the day after', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 23 * MILLIS_PER_HOUR;
const previousDuration = 3 * MILLIS_PER_HOUR;
const currentStart = 22 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 22 * MILLIS_PER_HOUR, dayOffset: 0 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 3 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
it('accounts for events that cross midnight', () => {
const previousStart = 20 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
const previousDuration = 6 * MILLIS_PER_HOUR;
const currentStart = 1 * MILLIS_PER_HOUR;
const expected = -MILLIS_PER_HOUR; // (previousEnd - currentStart);
expect(getTimeFromPrevious(currentStart, previousStart, previousEnd, previousDuration)).toBe(expected);
expect(
getTimeFromPrevious(
{ timeStart: 1 * MILLIS_PER_HOUR, dayOffset: 1 },
{ timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR, dayOffset: 0 },
),
).toBe(expected);
});
});
@@ -1,45 +1,33 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Utility returns the time elapsed (gap or overlap) from the previous
* It uses deconstructed parameters to simplify implementation in UI
* Utility returns the gap from previous event
*/
export function getTimeFromPrevious(
currentStart: number,
previousStart?: number,
previousEnd?: number,
previousDuration?: number,
current: Pick<OntimeEvent, 'timeStart' | 'dayOffset'>,
previous?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
): number {
// there is no previous event
if (previousStart === undefined || previousEnd === undefined || previousDuration === undefined) {
if (!previous) {
return 0;
}
const normalisedCurrentStart = current.timeStart + current.dayOffset * dayInMs;
const normalisedPreviousEnd = previous.timeStart + previous.duration + previous.dayOffset * dayInMs;
// event is linked to previous
if (currentStart === previousEnd) {
if (normalisedCurrentStart === normalisedPreviousEnd) {
return 0;
}
// event is the day after
if (checkIsNextDay(previousStart, currentStart, previousDuration)) {
// time from previous is difference between normalised start and previous end
return currentStart + dayInMs - previousEnd;
}
// event has a gap from previous
if (currentStart > previousEnd) {
if (normalisedCurrentStart > normalisedPreviousEnd) {
// time from previous is difference between start and previous end
return currentStart - previousEnd;
return normalisedCurrentStart - normalisedPreviousEnd;
}
// event overlaps with previous
const overlap = previousEnd - currentStart;
if (overlap > 0) {
// time is a negative number indicating the amount of overlap
return -overlap;
}
// we need to make sure we return a number, but there are no business cases for this
return 0;
return normalisedCurrentStart - normalisedPreviousEnd;
}
@@ -1,48 +1,52 @@
import { MILLIS_PER_HOUR } from './conversionUtils';
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from './conversionUtils';
import { isNewLatest } from './isNewLatest';
describe('isNewLatest', () => {
it('should be true if there is no previous', () => {
expect(isNewLatest(0, 60000)).toBeTruthy();
const current = { timeStart: 0, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = undefined;
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be false if current is contained in the previous', () => {
const current = {
timeStart: 21 * MILLIS_PER_HOUR + 10 * MILLIS_PER_MINUTE,
duration: 10 * MILLIS_PER_MINUTE,
dayOffset: 0,
};
const previous = { timeStart: 21 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(false);
});
it('should be true if it starts when the previous finishes', () => {
const nowStart = 10 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 10 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 10 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it starts the same day the previous finishes', () => {
const nowStart = 22 * MILLIS_PER_HOUR;
const nowEnd = 23 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 20 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 22 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it finishes after the previous, accounting for passing midnight', () => {
const nowStart = 1 * MILLIS_PER_HOUR;
const nowEnd = 3 * MILLIS_PER_HOUR;
const previousStart = 23 * MILLIS_PER_HOUR;
const previousEnd = 2 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 1 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it the next day', () => {
const nowStart = 8 * MILLIS_PER_HOUR;
const nowEnd = 10 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 8 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
it('should be true if it the next day (2)', () => {
const nowStart = 9 * MILLIS_PER_HOUR;
const nowEnd = 11 * MILLIS_PER_HOUR;
const previousStart = 9 * MILLIS_PER_HOUR;
const previousEnd = 11 * MILLIS_PER_HOUR;
expect(isNewLatest(nowStart, nowEnd, previousStart, previousEnd)).toBeTruthy();
const current = { timeStart: 9 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR, dayOffset: 1 };
const previous = { timeStart: 9 * MILLIS_PER_HOUR, duration: 11 * MILLIS_PER_HOUR, dayOffset: 0 };
expect(isNewLatest(current, previous)).toBe(true);
});
});
+11 -14
View File
@@ -1,24 +1,21 @@
import { checkIsNextDay } from './checkIsNextDay.js';
import type { OntimeEvent } from 'ontime-types';
import { dayInMs } from './conversionUtils.js';
/**
* Checks whether a new element is the latest in the list
*/
export function isNewLatest(timeStart: number, timeEnd: number, previousStart?: number, previousEnd?: number): boolean {
export function isNewLatest(
currentEvent: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
previousEvent?: Pick<OntimeEvent, 'timeStart' | 'duration' | 'dayOffset'>,
) {
// true if there is no previous
if (previousStart === undefined || previousEnd === undefined) {
if (!previousEvent) {
return true;
}
// true if it starts after the previous is finished
if (timeStart >= previousEnd) {
return true;
}
const normalisedCurrentEnd = currentEvent.timeStart + currentEvent.duration + currentEvent.dayOffset * dayInMs;
const normalisedPreviousEnd = previousEvent.timeStart + previousEvent.duration + previousEvent.dayOffset * dayInMs;
// true if it finishes later than previous
if (timeEnd > previousEnd) {
return true;
}
// true if it is the day after
return checkIsNextDay(previousStart, timeStart, previousEnd - previousStart);
return normalisedCurrentEnd >= normalisedPreviousEnd;
}
@@ -2,7 +2,7 @@ import type { MaybeNumber } from 'ontime-types';
import { millisToSeconds, secondsToHours, secondsToMinutes } from './conversionUtils.js';
function pad(val: number): string {
export function pad(val: number): string {
return String(val).padStart(2, '0');
}
+43 -16
View File
@@ -39,8 +39,8 @@ importers:
.:
devDependencies:
'@playwright/test':
specifier: ^1.42.1
version: 1.42.1
specifier: ^1.49.1
version: 1.49.1
'@types/node':
specifier: 'catalog:'
version: 20.14.10
@@ -288,6 +288,12 @@ importers:
'@googleapis/sheets':
specifier: ^5.0.5
version: 5.0.5
cookie:
specifier: ^1.0.2
version: 1.0.2
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
cors:
specifier: ^2.8.5
version: 2.8.5
@@ -1680,9 +1686,9 @@ packages:
resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@playwright/test@1.42.1':
resolution: {integrity: sha512-Gq9rmS54mjBL/7/MvBaNOBwbfnh7beHvS6oS4srqXFcQHpQCV1+c8JXWE8VLPyRDhgS3H8x8A7hztqI9VnwrAQ==}
engines: {node: '>=16'}
'@playwright/test@1.49.1':
resolution: {integrity: sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==}
engines: {node: '>=18'}
hasBin: true
'@popperjs/core@2.11.8':
@@ -2717,6 +2723,10 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie-parser@1.4.7:
resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==}
engines: {node: '>= 0.8.0'}
cookie-signature@1.0.6:
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
@@ -2724,6 +2734,14 @@ packages:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
@@ -4196,14 +4214,14 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
playwright-core@1.42.1:
resolution: {integrity: sha512-mxz6zclokgrke9p1vtdy/COWBH+eOZgYUVVU34C73M+4j4HLlQJHtfcqiqqxpP0o8HhMkflvfbquLX5dg6wlfA==}
engines: {node: '>=16'}
playwright-core@1.49.1:
resolution: {integrity: sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.42.1:
resolution: {integrity: sha512-PgwB03s2DZBcNRoW+1w9E+VkLBxweib6KTXM0M3tkiT4jVxKSi6PmVJ591J+0u10LUrgxB7dLRbiJqO5s2QPMg==}
engines: {node: '>=16'}
playwright@1.49.1:
resolution: {integrity: sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==}
engines: {node: '>=18'}
hasBin: true
plist@3.1.0:
@@ -6597,9 +6615,9 @@ snapshots:
'@pkgr/core@0.1.1': {}
'@playwright/test@1.42.1':
'@playwright/test@1.49.1':
dependencies:
playwright: 1.42.1
playwright: 1.49.1
'@popperjs/core@2.11.8': {}
@@ -7799,10 +7817,19 @@ snapshots:
convert-source-map@2.0.0: {}
cookie-parser@1.4.7:
dependencies:
cookie: 0.7.2
cookie-signature: 1.0.6
cookie-signature@1.0.6: {}
cookie@0.7.1: {}
cookie@0.7.2: {}
cookie@1.0.2: {}
copy-to-clipboard@3.3.3:
dependencies:
toggle-selection: 1.0.6
@@ -9494,11 +9521,11 @@ snapshots:
picomatch@2.3.1: {}
playwright-core@1.42.1: {}
playwright-core@1.49.1: {}
playwright@1.42.1:
playwright@1.49.1:
dependencies:
playwright-core: 1.42.1
playwright-core: 1.49.1
optionalDependencies:
fsevents: 2.3.2