mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 23:49:15 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f0b32d444 | |||
| 703dee35a4 | |||
| 2a7f5b7872 | |||
| c6eccec30e | |||
| 5220c2c374 | |||
| 4eeeb294f7 | |||
| a006331fea | |||
| ac0ef06459 | |||
| 4d04fe35c3 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/cli",
|
"name": "@getontime/cli",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-ui",
|
"name": "ontime-ui",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "1.6.0",
|
"@base-ui/react": "1.7.0",
|
||||||
"@codemirror/commands": "^6.0.0",
|
"@codemirror/commands": "^6.0.0",
|
||||||
"@codemirror/lang-css": "^6.0.0",
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
"@codemirror/state": "^6.0.0",
|
"@codemirror/state": "^6.0.0",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource/open-sans": "^5.2.7",
|
"@fontsource/open-sans": "^5.2.7",
|
||||||
"@mantine/hooks": "^8.3.7",
|
"@mantine/hooks": "^9.5.1",
|
||||||
"@sentry/react": "^10.59.0",
|
"@sentry/react": "^10.59.0",
|
||||||
"@table-nav/react": "^0.0.7",
|
"@table-nav/react": "^0.0.7",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-fast-compare": "^3.2.2",
|
"react-fast-compare": "^3.2.2",
|
||||||
"react-hook-form": "^7.80.0",
|
"react-hook-form": "^7.80.0",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.7.0",
|
||||||
"react-router": "^8.0.1",
|
"react-router": "^8.0.1",
|
||||||
"react-virtuoso": "^4.18.7",
|
"react-virtuoso": "^4.18.7",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sentry/vite-plugin": "5.1.1",
|
"@sentry/vite-plugin": "5.4.0",
|
||||||
|
"@types/node": "catalog:",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.12",
|
"@types/react": "^19.1.12",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
@@ -72,8 +73,8 @@
|
|||||||
"ontime-utils": "workspace:*",
|
"ontime-utils": "workspace:*",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "8.0.1",
|
"vite": "8.2.1",
|
||||||
"vite-plugin-compression2": "2.5.1",
|
"vite-plugin-compression2": "2.5.3",
|
||||||
"vite-plugin-svgr": "4.5.0",
|
"vite-plugin-svgr": "4.5.0",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ export async function postCloneEntry(
|
|||||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP request events duration to fit inside the group target
|
||||||
|
*/
|
||||||
|
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||||
|
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request for grouping a list of entries into a group
|
* HTTP request for grouping a list of entries into a group
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Dialog } from '@base-ui/react/dialog';
|
import { Dialog } from '@base-ui/react/dialog';
|
||||||
import { useDisclosure, useFullscreen } from '@mantine/hooks';
|
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
||||||
import { LuCoffee } from 'react-icons/lu';
|
import { LuCoffee } from 'react-icons/lu';
|
||||||
@@ -33,7 +33,7 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
const isSmallScreen = useIsSmallScreen();
|
const isSmallScreen = useIsSmallScreen();
|
||||||
|
|
||||||
const [isRenameOpen, handlers] = useDisclosure(false);
|
const [isRenameOpen, handlers] = useDisclosure(false);
|
||||||
const { fullscreen, toggle } = useFullscreen();
|
const { fullscreen, toggle } = useFullscreenDocument();
|
||||||
const { mirror, toggleMirror } = useViewOptionsStore();
|
const { mirror, toggleMirror } = useViewOptionsStore();
|
||||||
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
const { keepAwake, toggleKeepAwake } = useKeepAwakeOptions();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
requestEventSwap,
|
requestEventSwap,
|
||||||
requestGroupEntries,
|
requestGroupEntries,
|
||||||
requestUngroup,
|
requestUngroup,
|
||||||
|
requestFitGroupTarget,
|
||||||
} from '../api/rundown';
|
} from '../api/rundown';
|
||||||
import { logAxiosError } from '../api/utils';
|
import { logAxiosError } from '../api/utils';
|
||||||
import { useEditorSettings } from '../stores/editorSettings';
|
import { useEditorSettings } from '../stores/editorSettings';
|
||||||
@@ -466,7 +467,27 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
return previousEnd;
|
return previousEnd;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates time of existing event so it satisfies the group target duration
|
||||||
|
* @param eventId {EntryId} - id of the event
|
||||||
|
*/
|
||||||
|
const matchGroupDuration = useCallback(
|
||||||
|
async (eventId: EntryId) => {
|
||||||
|
const rundownId = getCurrentRundownData()?.id;
|
||||||
|
if (!rundownId) {
|
||||||
|
throw new Error('Rundown not initialised');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requestFitGroupTarget(rundownId, eventId);
|
||||||
|
} catch (error) {
|
||||||
|
logAxiosError('Error updating event', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getCurrentRundownData],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1009,6 +1030,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
|
matchGroupDuration,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
addEntry,
|
addEntry,
|
||||||
@@ -1026,6 +1048,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
|||||||
swapEvents,
|
swapEvents,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
updateTimer,
|
updateTimer,
|
||||||
|
matchGroupDuration,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,8 +200,7 @@ $card-padding: 2rem;
|
|||||||
.overlay {
|
.overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: $zindex-backdrop;
|
z-index: $zindex-backdrop;
|
||||||
width: 100%;
|
inset: 0;
|
||||||
height: 100%;
|
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-content: center;
|
place-content: center;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.updateIndicator {
|
||||||
|
width: 0.5em;
|
||||||
|
height: 0.5em;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 99px;
|
||||||
|
background-color: $red-400;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
|||||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './AppVersion.module.scss';
|
||||||
|
|
||||||
export default function AppVersion() {
|
export default function AppVersion() {
|
||||||
const { data, isError } = useAppVersion();
|
const { data, isError } = useAppVersion();
|
||||||
|
|
||||||
@@ -18,7 +20,12 @@ export default function AppVersion() {
|
|||||||
return (
|
return (
|
||||||
<Panel.ListItem>
|
<Panel.ListItem>
|
||||||
<Panel.Field
|
<Panel.Field
|
||||||
title={`Ontime ${appVersion}`}
|
title={
|
||||||
|
<>
|
||||||
|
<span className={style.updateIndicator} aria-hidden='true' />
|
||||||
|
{`Ontime ${appVersion}`}
|
||||||
|
</>
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
isOntimeCloud
|
isOntimeCloud
|
||||||
? `Version ${data.version} is available. Restart your stage to update.`
|
? `Version ${data.version} is available. Restart your stage to update.`
|
||||||
@@ -26,7 +33,7 @@ export default function AppVersion() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isOntimeCloud && (
|
{!isOntimeCloud && (
|
||||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||||
)}
|
)}
|
||||||
</Panel.ListItem>
|
</Panel.ListItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Panel.InlineElements>
|
</Panel.InlineElements>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<Panel.Loader isLoading={status === 'pending'} />
|
|
||||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
|
<Panel.Loader isLoading={status === 'pending'} />
|
||||||
{data.pendingRestart && (
|
{data.pendingRestart && (
|
||||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
|||||||
<div>
|
<div>
|
||||||
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
|
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
|
||||||
<TextLikeInput
|
<TextLikeInput
|
||||||
offset={planOffsetLabel}
|
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
|
||||||
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||||
import { isPlaybackActive } from 'ontime-utils';
|
import { isPlaybackActive } from 'ontime-utils';
|
||||||
import { MouseEvent, useEffect, useRef } from 'react';
|
import { MouseEvent, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -13,9 +13,10 @@ import {
|
|||||||
IoTrash,
|
IoTrash,
|
||||||
IoUnlink,
|
IoUnlink,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||||
|
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
|
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
@@ -102,7 +103,10 @@ export default function RundownEvent({
|
|||||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||||
|
|
||||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
|
||||||
|
|
||||||
|
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
|
||||||
|
useEntryActionsContext();
|
||||||
|
|
||||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||||
const unselect = useEventSelection((state) => state.unselect);
|
const unselect = useEventSelection((state) => state.unselect);
|
||||||
@@ -114,6 +118,15 @@ export default function RundownEvent({
|
|||||||
|
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
|
||||||
|
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
|
||||||
|
return [false, ''];
|
||||||
|
const { targetDuration, duration } = parentGroup;
|
||||||
|
return targetDuration > duration
|
||||||
|
? [true, 'Increase event duration to fit the group target']
|
||||||
|
: [true, 'Decrease event duration to fit the group target'];
|
||||||
|
})();
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
|
||||||
selectedEvents.size > 1
|
selectedEvents.size > 1
|
||||||
? [
|
? [
|
||||||
@@ -172,6 +185,17 @@ export default function RundownEvent({
|
|||||||
updateEntry({ id: eventId, flag: !flag });
|
updateEntry({ id: eventId, flag: !flag });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Match Group Target Duration',
|
||||||
|
description: groupTargetDurationDescription,
|
||||||
|
icon: TbClockPin,
|
||||||
|
onClick: () => {
|
||||||
|
if (!parent) return;
|
||||||
|
matchGroupDuration(eventId);
|
||||||
|
},
|
||||||
|
disabled: !enableMatchDuration,
|
||||||
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
|
|||||||
@@ -74,42 +74,36 @@
|
|||||||
.metaLabel {
|
.metaLabel {
|
||||||
color: $muted-gray;
|
color: $muted-gray;
|
||||||
font-size: calc(1rem - 3px);
|
font-size: calc(1rem - 3px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.strike {
|
|
||||||
text-decoration: wavy underline;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
color: $ui-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
.duration {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
color: $ui-white;
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
.strike {
|
||||||
|
// color: $playback-over;
|
||||||
|
text-decoration: wavy underline;
|
||||||
|
text-decoration-color: $playback-over;
|
||||||
|
}
|
||||||
|
.offsetLabel {
|
||||||
|
background-color: $playback-over;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lockIcon {
|
.lockIcon {
|
||||||
opacity: 0.6;
|
color: $muted-gray;
|
||||||
}
|
}
|
||||||
|
|
||||||
.over {
|
.target {
|
||||||
color: $playback-over;
|
display: contents;
|
||||||
.strike {
|
|
||||||
text-decoration-color: $playback-over;
|
|
||||||
}
|
|
||||||
.offsetLabel {
|
|
||||||
background-color: $playback-over;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.under {
|
|
||||||
color: $playback-under;
|
|
||||||
.strike {
|
|
||||||
text-decoration-color: $playback-under;
|
|
||||||
}
|
|
||||||
.offsetLabel {
|
|
||||||
background-color: $playback-under;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag {
|
.drag {
|
||||||
|
|||||||
@@ -2,24 +2,25 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||||
import { MouseEvent, useRef } from 'react';
|
import { MouseEvent, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
IoChevronDown,
|
IoChevronDown,
|
||||||
IoChevronUp,
|
IoChevronUp,
|
||||||
IoDuplicateOutline,
|
IoDuplicateOutline,
|
||||||
IoFolderOpenOutline,
|
IoFolderOpenOutline,
|
||||||
IoLockClosed,
|
|
||||||
IoReorderTwo,
|
IoReorderTwo,
|
||||||
IoTrash,
|
IoTrash,
|
||||||
|
IoLockClosed,
|
||||||
} from 'react-icons/io5';
|
} from 'react-icons/io5';
|
||||||
|
import { TbClockPin } from 'react-icons/tb';
|
||||||
|
|
||||||
import IconButton from '../../../common/components/buttons/IconButton';
|
import IconButton from '../../../common/components/buttons/IconButton';
|
||||||
import Tag from '../../../common/components/tag/Tag';
|
import Tag from '../../../common/components/tag/Tag';
|
||||||
|
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||||
import { getOffsetState } from '../../../common/utils/offset';
|
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||||
import TitleEditor from '../common/TitleEditor';
|
import TitleEditor from '../common/TitleEditor';
|
||||||
@@ -40,12 +41,31 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
'use memo';
|
'use memo';
|
||||||
|
|
||||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
|
||||||
|
|
||||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||||
|
|
||||||
|
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
|
||||||
|
|
||||||
|
const [planOffset, offset] = (() => {
|
||||||
|
if (data.targetDuration === null) {
|
||||||
|
return [null, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = data.duration - data.targetDuration;
|
||||||
|
if (offset === 0) {
|
||||||
|
return [null, 0];
|
||||||
|
}
|
||||||
|
const absOffset = Math.abs(offset);
|
||||||
|
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const matchDuration = useCallback(() => {
|
||||||
|
updateEntry({ id: data.id, targetDuration: data.duration });
|
||||||
|
}, [data.duration, data.id, updateEntry]);
|
||||||
|
|
||||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
@@ -62,6 +82,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
disabled: data.entries.length === 0,
|
disabled: data.entries.length === 0,
|
||||||
},
|
},
|
||||||
{ type: 'divider' },
|
{ type: 'divider' },
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Match Content Duration',
|
||||||
|
icon: TbClockPin,
|
||||||
|
onClick: matchDuration,
|
||||||
|
disabled: isDurationMatching,
|
||||||
|
description:
|
||||||
|
offset > 0
|
||||||
|
? "Increase group target duration to match it's contents"
|
||||||
|
: "Decrease group target duration to match it's contents",
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
type: 'item',
|
type: 'item',
|
||||||
label: 'Delete Group',
|
label: 'Delete Group',
|
||||||
@@ -105,22 +137,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||||
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||||
|
|
||||||
const [planOffset, planOffsetLabel] = (() => {
|
|
||||||
if (data.targetDuration === null) {
|
|
||||||
return [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
const offset = data.duration - data.targetDuration;
|
|
||||||
if (offset === 0) {
|
|
||||||
return [null, 'under'];
|
|
||||||
}
|
|
||||||
const absOffset = Math.abs(offset);
|
|
||||||
return [
|
|
||||||
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
|
|
||||||
getOffsetState(offset),
|
|
||||||
];
|
|
||||||
})();
|
|
||||||
|
|
||||||
const dragStyle = {
|
const dragStyle = {
|
||||||
zIndex: isDragging ? 2 : 'inherit',
|
zIndex: isDragging ? 2 : 'inherit',
|
||||||
transform: CSS.Translate.toString(transform),
|
transform: CSS.Translate.toString(transform),
|
||||||
@@ -175,20 +191,18 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
|||||||
<div className={style.metaLabel}>End</div>
|
<div className={style.metaLabel}>End</div>
|
||||||
<div>{formatTime(data.timeEnd)}</div>
|
<div>{formatTime(data.timeEnd)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.metaEntry}>
|
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
|
||||||
<div className={style.metaLabel}>Duration</div>
|
<div className={style.metaEntry}>
|
||||||
<div className={style.duration}>
|
<div className={style.metaLabel}>
|
||||||
{planOffset === null ? (
|
Duration
|
||||||
formatDuration(data.duration)
|
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||||
) : (
|
</div>
|
||||||
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
<div className={cx([style.duration, planOffset && style.warning])}>
|
||||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime-electron",
|
"name": "ontime-electron",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"author": "Carlos Valente",
|
"author": "Carlos Valente",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
"main": "src/main.js",
|
"main": "src/main.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "38.2.1",
|
"electron": "38.2.1",
|
||||||
"electron-builder": "26.9.1",
|
"electron-builder": "26.15.3",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^9.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
"dev:electron": "wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
|||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'New project...',
|
label: 'New project...',
|
||||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
click: () => redirectWindow('/editor?settings=project__create'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Load...',
|
label: 'Load...',
|
||||||
@@ -202,6 +202,18 @@ function makeSettingsMenu(redirectWindow) {
|
|||||||
label: 'View settings',
|
label: 'View settings',
|
||||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Custom views',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'MCP Server',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Server port',
|
||||||
|
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getontime/resolver",
|
"name": "@getontime/resolver",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/cpvalente/ontime",
|
"repository": "https://github.com/cpvalente/ontime",
|
||||||
"types": "./dist/main.d.ts",
|
"types": "./dist/main.d.ts",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ontime-server",
|
"name": "ontime-server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"exports": "./src/index.js",
|
"exports": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googleapis/sheets": "^5.0.5",
|
"@googleapis/sheets": "^5.0.5",
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
"cookie": "1.0.2",
|
"cookie": "1.0.2",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^17.0.0",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-static-gzip": "3.0.1",
|
"express-static-gzip": "3.0.1",
|
||||||
"express-validator": "7.3.2",
|
"express-validator": "7.3.2",
|
||||||
@@ -31,11 +31,11 @@
|
|||||||
"@types/multer": "2.1.0",
|
"@types/multer": "2.1.0",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
"esbuild": "^0.24.0",
|
"esbuild": "^0.28.0",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"server-timing": "^3.3.3",
|
"server-timing": "^3.3.3",
|
||||||
"ts-essentials": "catalog:",
|
"ts-essentials": "catalog:",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.23.12",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
TimerType,
|
TimerType,
|
||||||
Trigger,
|
Trigger,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||||
import { assertType } from 'vitest';
|
import { assertType } from 'vitest';
|
||||||
|
|
||||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
makeDeepClone,
|
makeDeepClone,
|
||||||
mergeRundownPreservingFields,
|
mergeRundownPreservingFields,
|
||||||
isLoadedPlayable,
|
isLoadedPlayable,
|
||||||
|
eventDurationMatchGroupTarget,
|
||||||
} from '../rundown.utils.js';
|
} from '../rundown.utils.js';
|
||||||
|
|
||||||
describe('test event validator', () => {
|
describe('test event validator', () => {
|
||||||
@@ -610,3 +611,107 @@ describe('isLoadedPlayable()', () => {
|
|||||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('eventDurationMatchGroupTarget()', () => {
|
||||||
|
it('returns unchanged duration when group already matches target', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR,
|
||||||
|
groupDuration: MILLIS_PER_HOUR,
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increases event duration when group is shorter than target', () => {
|
||||||
|
// Group is 1h short of target, so event duration increases by 1h
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
||||||
|
groupDuration: MILLIS_PER_HOUR, // 1h
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decreases event duration when group is longer than target', () => {
|
||||||
|
// Group is 30m over target, so event duration decreases by 30m
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR, // 1h
|
||||||
|
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero target duration', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: 0,
|
||||||
|
groupDuration: MILLIS_PER_HOUR,
|
||||||
|
eventDuration: MILLIS_PER_HOUR,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero group duration', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR,
|
||||||
|
groupDuration: 0,
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero event duration', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR,
|
||||||
|
groupDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
eventDuration: 0,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all zero values', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: 0,
|
||||||
|
groupDuration: 0,
|
||||||
|
eventDuration: 0,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when result would be negative', () => {
|
||||||
|
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
groupDuration: MILLIS_PER_HOUR * 2,
|
||||||
|
eventDuration: MILLIS_PER_HOUR,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles large durations', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
||||||
|
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
||||||
|
eventDuration: MILLIS_PER_HOUR, // 1h
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when targetDuration is null', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: null,
|
||||||
|
groupDuration: MILLIS_PER_HOUR,
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when duration would be over 24h', () => {
|
||||||
|
const result = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: 30 * MILLIS_PER_HOUR,
|
||||||
|
groupDuration: MILLIS_PER_HOUR,
|
||||||
|
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||||
|
});
|
||||||
|
expect(result).toStrictEqual(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
reorderEntry,
|
reorderEntry,
|
||||||
swapEvents,
|
swapEvents,
|
||||||
ungroupEntries,
|
ungroupEntries,
|
||||||
|
entryFitGroupDuration,
|
||||||
} from './rundown.service.js';
|
} from './rundown.service.js';
|
||||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||||
import {
|
import {
|
||||||
@@ -337,6 +338,23 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a events duration to fit inside the group target
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
'/:rundownId/:id/fit-group-duration',
|
||||||
|
paramsWithId,
|
||||||
|
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||||
|
try {
|
||||||
|
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
|
||||||
|
res.status(200).send(rundown);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
res.status(400).send({ message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a list of entries by their ID
|
* Deletes a list of entries by their ID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
hasChanges,
|
hasChanges,
|
||||||
mergeRundownPreservingFields,
|
mergeRundownPreservingFields,
|
||||||
isLoadedPlayable,
|
isLoadedPlayable,
|
||||||
|
eventDurationMatchGroupTarget,
|
||||||
} from './rundown.utils.js';
|
} from './rundown.utils.js';
|
||||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||||
|
|
||||||
@@ -447,6 +448,69 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
|
|||||||
return rundownResult;
|
return rundownResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a events duration to fit inside the group target
|
||||||
|
*/
|
||||||
|
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
|
||||||
|
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||||
|
|
||||||
|
const entry = rundown.entries[entryId];
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error('Entry not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOntimeEvent(entry)) {
|
||||||
|
throw new Error('Entry must be an event');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { parent } = entry;
|
||||||
|
if (!parent) {
|
||||||
|
throw new Error('Entry must be in a group');
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = rundown.entries[parent];
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
throw new Error('Group not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOntimeGroup(group)) {
|
||||||
|
throw new Error('Group is not a group');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newDuration = eventDurationMatchGroupTarget({
|
||||||
|
targetDuration: group.targetDuration,
|
||||||
|
groupDuration: group.duration,
|
||||||
|
eventDuration: entry.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newDuration === null) {
|
||||||
|
throw new Error('Unable to fit a duration');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEnd = entry.timeStart + newDuration;
|
||||||
|
|
||||||
|
rundownMutation.edit(rundown, {
|
||||||
|
id: entryId,
|
||||||
|
duration: newDuration,
|
||||||
|
timeEnd: newEnd,
|
||||||
|
timeStrategy: entry.timeStrategy,
|
||||||
|
});
|
||||||
|
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||||
|
|
||||||
|
// schedule the side effects
|
||||||
|
setImmediate(() => {
|
||||||
|
// notify runtime that rundown has changed
|
||||||
|
updateRuntimeOnChange(rundownMetadata);
|
||||||
|
|
||||||
|
// we need to notify the timer since we might be changing a running event
|
||||||
|
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return rundownResult;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Groups a list of entries into a new group
|
* Groups a list of entries into a new group
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
EntryCustomFields,
|
EntryCustomFields,
|
||||||
EntryId,
|
EntryId,
|
||||||
ImportedFields,
|
ImportedFields,
|
||||||
|
Maybe,
|
||||||
OntimeBaseEvent,
|
OntimeBaseEvent,
|
||||||
OntimeDelay,
|
OntimeDelay,
|
||||||
OntimeEntry,
|
OntimeEntry,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
generateId,
|
generateId,
|
||||||
getCueCandidate,
|
getCueCandidate,
|
||||||
makeString,
|
makeString,
|
||||||
|
maxDuration,
|
||||||
validateEndAction,
|
validateEndAction,
|
||||||
validateTimerType,
|
validateTimerType,
|
||||||
validateTimes,
|
validateTimes,
|
||||||
@@ -351,19 +353,19 @@ export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundo
|
|||||||
|
|
||||||
/** List of event properties which do not need the rundown to be regenerated */
|
/** List of event properties which do not need the rundown to be regenerated */
|
||||||
enum RegenerateWhitelist {
|
enum RegenerateWhitelist {
|
||||||
'id', // adding it for completeness, users cannot change ID
|
id, // adding it for completeness, users cannot change ID
|
||||||
'type', // adding it for completeness, users cannot change ID
|
type, // adding it for completeness, users cannot change ID
|
||||||
'cue',
|
cue,
|
||||||
'title',
|
title,
|
||||||
'note',
|
note,
|
||||||
'endAction',
|
endAction,
|
||||||
'timerType',
|
timerType,
|
||||||
'countToEnd',
|
countToEnd,
|
||||||
'colour',
|
colour,
|
||||||
'timeWarning',
|
timeWarning,
|
||||||
'timeDanger',
|
timeDanger,
|
||||||
'custom',
|
custom,
|
||||||
'triggers',
|
triggers,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -601,3 +603,27 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
|
|||||||
precision,
|
precision,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts an event's duration to fit inside the group target
|
||||||
|
* @param targetDuration - The desired total duration for the group, or null
|
||||||
|
* @param groupDuration - The current total duration of all events in the group
|
||||||
|
* @param eventDuration - The current duration of the event being adjusted
|
||||||
|
* @returns The adjusted event duration, or null if targetDuration is null or
|
||||||
|
* the result would be negative
|
||||||
|
*/
|
||||||
|
export function eventDurationMatchGroupTarget({
|
||||||
|
targetDuration,
|
||||||
|
groupDuration,
|
||||||
|
eventDuration,
|
||||||
|
}: {
|
||||||
|
targetDuration: Maybe<number>;
|
||||||
|
groupDuration: number;
|
||||||
|
eventDuration: number;
|
||||||
|
}): Maybe<number> {
|
||||||
|
if (targetDuration === null) return null;
|
||||||
|
if (targetDuration === groupDuration) return null;
|
||||||
|
const durationDiff = targetDuration - groupDuration;
|
||||||
|
const newDuration = eventDuration + durationDiff;
|
||||||
|
return newDuration < 0 || newDuration > maxDuration ? null : newDuration;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
||||||
|
|
||||||
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the app authenticate middleware with support for the Authorization header.
|
|
||||||
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
|
|
||||||
* rather than cookies or query params; any other request falls through to the
|
|
||||||
* app middleware, keeping the behaviour of the shared middleware untouched.
|
|
||||||
*/
|
|
||||||
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
|
|
||||||
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
if (hasPassword) {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback(req, res, next);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -20,12 +20,9 @@ export function createMcpServer(): Server {
|
|||||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
server.setRequestHandler(
|
server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => ({
|
||||||
ListToolsRequestSchema,
|
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
||||||
async (): Promise<ListToolsResult> => ({
|
}));
|
||||||
tools: TOOL_DEFINITIONS as unknown as ListToolsResult['tools'],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => {
|
||||||
const { name, arguments: args = {} } = request.params;
|
const { name, arguments: args = {} } = request.params;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
|||||||
// Import Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
import { integrationRouter } from './api-integration/integration.router.js';
|
import { integrationRouter } from './api-integration/integration.router.js';
|
||||||
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
|
|
||||||
import { mcpRouter } from './api-mcp/mcp.router.js';
|
import { mcpRouter } from './api-mcp/mcp.router.js';
|
||||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||||
// Services
|
// Services
|
||||||
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
|||||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import type { IncomingMessage } from 'node:http';
|
||||||
|
|
||||||
import { isPublicAssetRequest } from '../authenticate.js';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../api-data/session/session.service.js', () => ({
|
||||||
|
hasPassword: true,
|
||||||
|
hashedPassword: 'valid-token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
|
||||||
|
|
||||||
|
function makeResponse() {
|
||||||
|
return {
|
||||||
|
redirect: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
||||||
|
return {
|
||||||
|
cookie,
|
||||||
|
get authorization(): never {
|
||||||
|
throw new Error('Authorization header should not be read');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('isPublicAssetRequest()', () => {
|
describe('isPublicAssetRequest()', () => {
|
||||||
it('allows root public assets without a prefix', () => {
|
it('allows root public assets without a prefix', () => {
|
||||||
@@ -18,3 +43,144 @@ describe('isPublicAssetRequest()', () => {
|
|||||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bearer authentication', () => {
|
||||||
|
const next = vi.fn() as NextFunction;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
next.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritises cookie authentication for API requests', () => {
|
||||||
|
const { authenticate } = makeAuthenticateMiddleware('');
|
||||||
|
const req = {
|
||||||
|
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||||
|
headers: makeHeadersWithFailingAuthorization(),
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritises cookie authentication for redirecting routes', () => {
|
||||||
|
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||||
|
const req = {
|
||||||
|
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
||||||
|
headers: makeHeadersWithFailingAuthorization(),
|
||||||
|
originalUrl: '/external/image.png',
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
||||||
|
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
||||||
|
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
||||||
|
|
||||||
|
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates API requests with a bearer token', () => {
|
||||||
|
const { authenticate } = makeAuthenticateMiddleware('');
|
||||||
|
const req = {
|
||||||
|
cookies: {},
|
||||||
|
headers: { authorization: 'Bearer valid-token' },
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
const res = makeResponse();
|
||||||
|
|
||||||
|
authenticate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.status).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
|
||||||
|
const { authenticate } = makeAuthenticateMiddleware('');
|
||||||
|
const req = {
|
||||||
|
cookies: {},
|
||||||
|
headers: { authorization: 'bearer valid-token ' },
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
authenticate(req, makeResponse(), next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates redirecting routes with a bearer token', () => {
|
||||||
|
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
||||||
|
const req = {
|
||||||
|
cookies: {},
|
||||||
|
headers: { authorization: 'Bearer valid-token' },
|
||||||
|
originalUrl: '/stage/external/image.png',
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
const res = makeResponse();
|
||||||
|
|
||||||
|
authenticateAndRedirect(req, res, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticates WebSocket handshakes with a bearer token', () => {
|
||||||
|
const req = {
|
||||||
|
headers: { authorization: 'Bearer valid-token' },
|
||||||
|
} as IncomingMessage;
|
||||||
|
|
||||||
|
authenticateSocket({} as never, req, next);
|
||||||
|
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(next).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid bearer token', () => {
|
||||||
|
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
||||||
|
const req = {
|
||||||
|
cookies: {},
|
||||||
|
headers: { authorization: 'Bearer invalid-token' },
|
||||||
|
query: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
const res = makeResponse();
|
||||||
|
|
||||||
|
authenticate(req, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
||||||
|
|
||||||
|
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
|
||||||
|
const redirectRes = makeResponse();
|
||||||
|
authenticateAndRedirect(redirectReq, redirectRes, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
|
||||||
|
|
||||||
|
const socketNext = vi.fn();
|
||||||
|
authenticateSocket(
|
||||||
|
{} as never,
|
||||||
|
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
|
||||||
|
socketNext,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(socketNext).toHaveBeenCalledOnce();
|
||||||
|
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
|
||||||
|
'rejects lookalike WebSocket query tokens in %s',
|
||||||
|
(url) => {
|
||||||
|
const socketNext = vi.fn();
|
||||||
|
|
||||||
|
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
|
||||||
|
|
||||||
|
expect(socketNext).toHaveBeenCalledOnce();
|
||||||
|
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||||
|
|
||||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
if (req.query.token) {
|
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||||
if (req.query.token === hashedPassword) {
|
return next();
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.cookies?.token) {
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
return next();
|
||||||
if (tokenFromCookie === hashedPassword) {
|
}
|
||||||
return next();
|
|
||||||
}
|
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).send('Unauthorized');
|
res.status(401).send('Unauthorized');
|
||||||
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// we expect the token to be in the cookies
|
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||||
if (req.cookies?.token) {
|
return next();
|
||||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
}
|
||||||
if (tokenFromCookie === hashedPassword) {
|
|
||||||
return next();
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
}
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
// 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 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) {
|
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||||
if (hashedPassword !== undefined) {
|
if (hashedPassword !== undefined) {
|
||||||
setSessionCookie(res, hashedPassword, prefix);
|
setSessionCookie(res, hashedPassword, prefix);
|
||||||
}
|
}
|
||||||
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the token is in the cookie
|
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
||||||
const cookieString = req.headers.cookie;
|
|
||||||
if (typeof cookieString === 'string') {
|
|
||||||
const cookies = parseCookie(cookieString);
|
|
||||||
if (cookies.token) {
|
|
||||||
const token = getTokenFromCookie(cookies.token);
|
|
||||||
if (token === hashedPassword) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if token is in the params - simple string check first
|
|
||||||
const urlString = req.url || '';
|
|
||||||
if (urlString.includes(`token=${hashedPassword}`)) {
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to full URL parsing for other formats
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
try {
|
return next();
|
||||||
const url = new URL(urlString, `http://${req.headers.host}`);
|
}
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
if (token === hashedPassword) {
|
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore URL parsing errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(new Error('Unauthorized'));
|
return next(new Error('Unauthorized'));
|
||||||
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
||||||
* When calling this function we already know a cookie called 'token' exists
|
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
||||||
* And want to extract its value
|
if (typeof cookieContents !== 'string') {
|
||||||
*/
|
return undefined;
|
||||||
function getTokenFromCookie(cookieContents: string): string | undefined {
|
}
|
||||||
// Fast path: check if the hashed password is directly in the cookie string
|
|
||||||
// This avoids JSON parsing for the common case
|
// Fast path: avoid JSON parsing when the expected token can be found directly
|
||||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
const cookieTokenString = '"token":"' + hashedPassword + '"';
|
||||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to JSON parsing for other cases or validation
|
|
||||||
try {
|
try {
|
||||||
const cookie = JSON.parse(cookieContents);
|
const cookie = JSON.parse(cookieContents);
|
||||||
if (cookie && typeof cookie.token === 'string') {
|
if (cookie && typeof cookie.token === 'string') {
|
||||||
@@ -203,3 +184,19 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
|
|||||||
// no error handling to do here
|
// no error handling to do here
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
|
||||||
|
return authorization?.match(/^Bearer\s+(\S+)\s*$/i)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenFromParams(params: string | Record<string, unknown> | undefined, host?: string): string | undefined {
|
||||||
|
if (typeof params !== 'string') {
|
||||||
|
return typeof params?.token === 'string' ? params.token : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
|
||||||
|
} catch (_) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ test('Move', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
|
||||||
|
|
||||||
// create events
|
// create events
|
||||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
|
||||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
||||||
|
|
||||||
// copy move down
|
// copy move down
|
||||||
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
||||||
@@ -86,15 +90,16 @@ test('Move', async ({ page }) => {
|
|||||||
.press('Alt+Control+ArrowDown');
|
.press('Alt+Control+ArrowDown');
|
||||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
|
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
|
||||||
|
|
||||||
// copy move up
|
// move entry three up twice, waiting for each reorder before targeting its new row
|
||||||
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
|
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
|
||||||
await page
|
await page
|
||||||
.getByTestId('entry-3')
|
.getByTestId('entry-3')
|
||||||
.getByTestId('rundown-event')
|
.getByTestId('rundown-event')
|
||||||
.filter({ hasText: '3' })
|
.filter({ hasText: '3' })
|
||||||
.press('Alt+ControlOrMeta+ArrowUp');
|
.press('Alt+ControlOrMeta+ArrowUp');
|
||||||
|
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
|
||||||
await page
|
await page
|
||||||
.getByTestId('entry-3')
|
.getByTestId('entry-2')
|
||||||
.getByTestId('rundown-event')
|
.getByTestId('rundown-event')
|
||||||
.filter({ hasText: '3' })
|
.filter({ hasText: '3' })
|
||||||
.press('Alt+ControlOrMeta+ArrowUp');
|
.press('Alt+ControlOrMeta+ArrowUp');
|
||||||
|
|||||||
@@ -13,23 +13,27 @@ test('time until absolute', async ({ context }) => {
|
|||||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||||
|
|
||||||
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
|
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||||
const ids = new Array<string>();
|
const entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
|
||||||
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
|
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
|
||||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||||
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
|
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
|
||||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||||
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
|
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
|
||||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||||
|
|
||||||
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
|
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
|
||||||
|
|
||||||
// Create reusable locator references for different elements
|
// Create reusable locator references for different elements
|
||||||
const entry2 = {
|
const entry2 = {
|
||||||
@@ -57,8 +61,10 @@ test('time until absolute', async ({ context }) => {
|
|||||||
|
|
||||||
await editor.getByRole('button', { name: 'Absolute' }).click();
|
await editor.getByRole('button', { name: 'Absolute' }).click();
|
||||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||||
|
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||||
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
|
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
|
||||||
await editor.getByLabel('Pause event').click();
|
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||||
|
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||||
|
|
||||||
// 1. initial check
|
// 1. initial check
|
||||||
await expect(entry2.editorEvent).toContainText('9m');
|
await expect(entry2.editorEvent).toContainText('9m');
|
||||||
@@ -129,22 +135,29 @@ test('time until absolute', async ({ context }) => {
|
|||||||
|
|
||||||
test('time until relative', async ({ context }) => {
|
test('time until relative', async ({ context }) => {
|
||||||
const editor = await context.newPage();
|
const editor = await context.newPage();
|
||||||
editor.goto('/editor');
|
await editor.goto('/editor');
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Edit' }).click();
|
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||||
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||||
|
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||||
|
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||||
|
|
||||||
await editor.getByRole('button', { name: 'Relative' }).click();
|
await editor.getByRole('button', { name: 'Relative' }).click();
|
||||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||||
|
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||||
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||||
await editor.getByLabel('Pause event').click();
|
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||||
|
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||||
|
|
||||||
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
|
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
|
||||||
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
|
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ontime",
|
"name": "ontime",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"description": "Time keeping for live events",
|
"description": "Time keeping for live events",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ontime",
|
"ontime",
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
"format:check": "oxfmt --check"
|
"format:check": "oxfmt --check"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.60.0",
|
"@playwright/test": "1.62.1",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"oxfmt": "^0.42.0",
|
"oxfmt": "^0.63.0",
|
||||||
"oxlint": "^1.57.0",
|
"oxlint": "^1.78.0",
|
||||||
"oxlint-tsgolint": "^0.17.4",
|
"oxlint-tsgolint": "^7.0.2001",
|
||||||
"rimraf": "catalog:",
|
"rimraf": "catalog:",
|
||||||
"turbo": "2.8.20",
|
"turbo": "2.10.10",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
"packageManager": "pnpm@11.1.2+sha512.415a1cc25974731e75455c1468371be74c5aa5fb7621b50d4056d222451609f11412f23fd602e6169f1e060466641f798597e1be961a10688836a67b16569499",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"name": "ontime-types",
|
"name": "ontime-types",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"nanoid": "^6.0.0"
|
"nanoid": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "catalog:",
|
||||||
"ontime-types": "workspace:*",
|
"ontime-types": "workspace:*",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
|
|||||||
Generated
+1108
-2093
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -4,10 +4,10 @@ packages:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.11
|
||||||
rimraf: 6.0.1
|
rimraf: 6.1.3
|
||||||
ts-essentials: 10.1.1
|
ts-essentials: 10.2.1
|
||||||
typescript: 7.0.2
|
typescript: 7.0.2
|
||||||
vitest: 4.0.17
|
vitest: 4.1.10
|
||||||
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@parcel/watcher': true
|
'@parcel/watcher': true
|
||||||
|
|||||||
Reference in New Issue
Block a user