feat: event cue (#473)

* feat: parse cue

* refactor: get delay from backend

* feat: add cue to UI

* refactor: remove deprecated delay logic

* refactor: extract studio clock specific logic

* refactor: extract utilities

* refactor: fix issue with missing key

* style: prevent cue overflow

* style: prevent whitespace wrap

* feat: add support for cues in integrations
This commit is contained in:
Carlos Valente
2023-08-17 21:43:11 +02:00
committed by GitHub
parent 51c31adaf1
commit 657cc22b44
62 changed files with 1363 additions and 1290 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import withData from './features/viewers/ViewWrapper';
import withAlias from './features/AliasWrapper';
import withData from './features/viewers/ViewWrapper';
const Editor = lazy(() => import('./features/editors/ProtectedEditor'));
const Cuesheet = lazy(() => import('./features/cuesheet/ProtectedCuesheet'));
+8 -3
View File
@@ -6,9 +6,14 @@ import { addLog } from '../stores/logger';
import { nowInMillis } from '../utils/time';
export function logAxiosError(prepend: string, error: unknown) {
const message = axios.isAxiosError(error)
? `${prepend} ${(error as AxiosError).response?.statusText ?? ''}: ${(error as AxiosError).response?.data ?? ''}`
: `${prepend}: ${error}`;
let message;
if (axios.isAxiosError(error)) {
const statusText = (error as AxiosError).response?.statusText ?? '';
const data = (error as AxiosError).response?.data ?? '';
message = `${prepend} ${statusText}: ${data}`;
} else {
message = `${prepend}: ${error}`;
}
addLog({
id: generateId(),
@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorTitles';
import { TitleActions } from '../../../../features/event-editor/composite/EventEditorDataLeft';
import Swatch from './Swatch';
@@ -34,8 +34,8 @@ export default function Schedule({ className }: ScheduleProps) {
<ScheduleItem
key={event.id}
selected={selectedState}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
timeStart={event.timeStart + (event?.delay ?? 0)}
timeEnd={event.timeEnd + (event?.delay ?? 0)}
title={event.title}
colour={isBackstage ? event.colour : ''}
backstageEvent={!event.isPublic}
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { swapOntimeEvents } from 'ontime-utils';
import { getCueCandidate, swapOntimeEvents } from 'ontime-utils';
import { RUNDOWN_TABLE, RUNDOWN_TABLE_KEY } from '../api/apiConstants';
import { logAxiosError } from '../api/apiUtils';
@@ -67,16 +67,20 @@ export const useEventAction = () => {
after: options?.after,
};
if (newEvent?.cue === undefined) {
newEvent.cue = getCueCandidate(queryClient.getQueryData(RUNDOWN_TABLE) || [], options?.after);
}
// hard coding duration value to be as expected for now
// this until timeOptions gets implemented
if (typeof newEvent?.timeStart !== 'undefined' && typeof newEvent.timeEnd !== 'undefined') {
if (newEvent?.timeStart !== undefined && newEvent.timeEnd !== undefined) {
newEvent.duration = Math.max(0, newEvent?.timeEnd - newEvent?.timeStart) || 0;
}
if (applicationOptions.startTimeIsLastEnd && applicationOptions?.lastEventId) {
const rundown = queryClient.getQueryData(RUNDOWN_TABLE) as OntimeRundown;
const previousEvent = rundown.find((event) => event.id === applicationOptions.lastEventId);
if (typeof previousEvent !== 'undefined' && previousEvent.type === 'event') {
if (previousEvent !== undefined && previousEvent.type === 'event') {
newEvent.timeStart = previousEvent.timeEnd;
newEvent.timeEnd = previousEvent.timeEnd;
}
@@ -126,7 +130,6 @@ export const useEventAction = () => {
onError: (_error, _newEvent, context) => {
queryClient.setQueryData([RUNDOWN_TABLE_KEY, context?.newEvent.id], context?.previousEvent);
},
// Mutation finished, failed or successful
// Fetch anyway, just to be sure
onSettled: async () => {
@@ -1,536 +0,0 @@
import { formatEventList, getEventsWithDelay, trimRundown } from '../eventsManager';
describe('getEventsWithDelay function', () => {
test('with positive delays', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000,
timeEnd: 720000,
colour: '',
type: 'event',
id: '8222',
},
{
duration: 900000,
type: 'delay',
revision: 0,
id: 'a386',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000,
timeEnd: 38520000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000,
timeEnd: 45120000,
colour: '',
type: 'event',
id: '2651',
},
{
type: 'block',
id: 'e6a1',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
{
title: 'Use simpler times to create a timer',
timeStart: 120000 + 60000,
timeEnd: 720000 + 60000,
colour: '',
type: 'event',
id: '8222',
},
{
title: 'Add delay blocks to affect all events',
timeStart: 37320000 + 60000 + 900000,
timeEnd: 38520000 + 60000 + 900000,
colour: '',
type: 'event',
id: '6dce',
},
{
title: 'Add and remove events with [+] and [-]',
timeStart: 38520000 + 60000 + 900000,
timeEnd: 45120000 + 60000 + 900000,
colour: '',
type: 'event',
id: '2651',
},
{
title: 'And control whether they are public',
timeStart: 46800000,
timeEnd: 57600000,
colour: '',
type: 'event',
id: '1358',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
test('with negative delays', () => {
const testData = [
{
duration: -20,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 100,
timeEnd: 200,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 80,
timeEnd: 180,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('getEventsWithDelay edge cases', () => {
it('ensures time start cannot be below 0', () => {
const testData = [
{
duration: -200,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 0,
timeEnd: 0,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('does not modify original array', () => {
const testData = [
{
duration: 10,
type: 'delay',
id: '24240',
},
{
title: 'Welcome to Ontime',
timeStart: 10,
timeEnd: 20,
colour: '',
type: 'event',
id: '5946',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
const expectedSafe = [
{
title: 'Welcome to Ontime',
timeStart: 20,
timeEnd: 30,
colour: '',
type: 'event',
id: '5946',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
expect(getEventsWithDelay(expectedSafe)).toStrictEqual(expected);
});
it('given an empty array', () => {
const emptyArray = {
test: [],
expect: [],
};
expect(getEventsWithDelay(emptyArray.test)).toStrictEqual(emptyArray.expect);
});
it('given an undefined object', () => {
const withUndefined = {
test: undefined,
expect: [],
};
expect(getEventsWithDelay(withUndefined.test)).toStrictEqual(withUndefined.expect);
});
it('given a corrupted event object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
duration: 60000,
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000 + 60000,
timeEnd: 35520000 + 60000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
it('given a corrupted delay object', () => {
const testData = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
type: 'delay',
id: '24240',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
const expected = [
{
title: 'Welcome to Ontime',
timeStart: 28800000,
timeEnd: 30600000,
colour: '',
type: 'event',
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
timeStart: 34920000,
timeEnd: 35520000,
colour: '',
type: 'event',
id: '8ee5',
},
];
expect(getEventsWithDelay(testData)).toStrictEqual(expected);
});
});
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData, selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: 'event',
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent, selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,55 @@
import { EndAction, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
import { cloneEvent } from '../eventsManager';
describe('cloneEvent()', () => {
it('creates a stem from a given event', () => {
const original = {
id: 'unique',
type: SupportedEvent.Event,
title: 'title',
cue: 'cue',
subtitle: 'subtitle',
presenter: 'presenter',
note: 'note',
timeStart: 0,
duration: 10,
timeEnd: 10,
timerType: TimerType.CountDown,
endAction: EndAction.None,
isPublic: false,
skip: false,
colour: 'F00',
revision: 10,
user0: 'user0',
user1: 'user1',
user2: 'user2',
user3: 'user3',
user4: 'user4',
user5: 'user5',
user6: 'user6',
user7: 'user7',
user8: 'user8',
user9: 'user9',
} as OntimeEvent;
const cloned = cloneEvent(original);
expect(cloned).not.toBe(original);
// @ts-expect-error -- safeguarding this
expect(cloned?.id).toBe(undefined);
expect(cloned.title).toBe(original.title);
expect(cloned.subtitle).toBe(original.subtitle);
expect(cloned.presenter).toBe(original.presenter);
expect(cloned.note).toBe(original.note);
expect(cloned.endAction).toBe(original.endAction);
expect(cloned.timerType).toBe(original.timerType);
expect(cloned.timeStart).toBe(original.timeStart);
expect(cloned.timeEnd).toBe(original.timeEnd);
expect(cloned.duration).toBe(original.duration);
expect(cloned.isPublic).toBe(original.isPublic);
expect(cloned.skip).toBe(original.skip);
expect(cloned.colour).toBe(original.colour);
expect(cloned.type).toBe(SupportedEvent.Event);
expect(cloned.revision).toBe(0);
});
});
@@ -1,56 +0,0 @@
import getDelayTo from '../getDelayTo';
describe('getDelayTo function', () => {
it('handles list with delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 0);
expect(notDelayed).toBe(0);
const delayedEvent = getDelayTo(events, 2);
expect(delayedEvent).toBe(delayDuration);
});
it('handles list without delays', () => {
const events = [{ type: 'event' }, { type: 'event' }];
const notDelayed = getDelayTo(events, 1);
expect(notDelayed).toBe(0);
});
it('handles list with multiple delays', () => {
const delayDuration = 100;
const events = [
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
{ type: 'delay', duration: delayDuration },
{ type: 'event' },
];
const doubleDelay = getDelayTo(events, 4);
expect(doubleDelay).toBe(delayDuration * 2);
});
it('handles list with blocks', () => {
const events = [
{ type: 'event' },
{ type: 'delay', duration: 100 },
{ type: 'event' },
{ type: 'block' },
{ type: 'event' },
];
const notDelayed = getDelayTo(events, 4);
expect(notDelayed).toBe(0);
});
it('handles index greater than list', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, 3);
expect(notDelayed).toBe(0);
});
it('handles negative index (not found)', () => {
const events = [{ type: 'event' }, { type: 'delay', duration: 100 }, { type: 'event' }];
const notDelayed = getDelayTo(events, -1);
expect(notDelayed).toBe(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { Alias } from 'ontime-types';
import isEqual from 'react-fast-compare';
import { Location, resolvePath } from 'react-router-dom';
import { Alias } from 'ontime-types';
/**
* Validates an alias against defined parameters
+13 -155
View File
@@ -1,174 +1,32 @@
import { OntimeEvent, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { formatTime } from './time';
/**
* @description From a list of events, returns only events of type event with calculated delays
* @param {Object[]} rundown - given rundown
* @returns {Object[]} Filtered events with calculated delays
*/
export const getEventsWithDelay = (rundown: OntimeRundownEntry[]): OntimeEvent[] => {
if (rundown == null) return [];
const delayedEvents: OntimeEvent[] = [];
// Add running delay
let delay = 0;
for (const event of rundown) {
if (event.type === SupportedEvent.Block) delay = 0;
else if (event.type === SupportedEvent.Delay) {
if (typeof event.duration === 'number') {
delay += event.duration;
}
} else if (event.type === SupportedEvent.Event) {
const delayedEvent = { ...event };
if (delay !== 0) {
delayedEvent.timeStart = Math.max(delayedEvent.timeStart + delay, 0);
delayedEvent.timeEnd = Math.max(delayedEvent.timeEnd + delay, 0);
}
delayedEvents.push(delayedEvent);
}
}
return delayedEvents;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
const givenEvents = [...rundown];
// format list
const formattedEvents = [];
for (const event of givenEvents) {
const start = formatTime(event.timeStart);
const end = formatTime(event.timeEnd);
formattedEvents.push({
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
});
}
return formattedEvents;
};
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
import { OntimeEvent, SupportedEvent } from 'ontime-types';
/**
* @description Creates a safe duplicate of an event
* @param {object} event
* @return {object} clean event
* @param {OntimeEvent} event
* @param {string} [after]
* @return {OntimeEvent} clean event
*/
type ClonedEvent = OntimeEvent | { after?: string };
type ClonedEvent = Omit<
OntimeEvent,
'id' | 'user0' | 'user1' | 'user2' | 'user3' | 'user4' | 'user5' | 'user6' | 'user7' | 'user8' | 'user9'
>;
export const cloneEvent = (event: OntimeEvent, after?: string): ClonedEvent => {
return {
type: SupportedEvent.Event,
title: event.title,
cue: event.cue,
subtitle: event.subtitle,
presenter: event.presenter,
note: event.note,
timeStart: event.timeStart,
duration: event.duration,
timeEnd: event.timeEnd,
timerType: event.timerType,
endAction: event.endAction,
isPublic: event.isPublic,
skip: event.skip,
colour: event.colour,
after: after,
revision: 0,
};
};
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
@@ -1,25 +0,0 @@
/**
* @description calculates delay to a given event
* @param {array} events
* @param {number} eventIndex
* @return {number} - delay value of given event
*/
export default function getDelayTo(events, eventIndex) {
let delay = 0;
let index = 0;
if (eventIndex >= 0) {
for (const event of events) {
if (eventIndex === index) {
return delay;
}
if (event.type === 'delay') {
delay += event.duration;
} else if (event.type === 'block') {
delay = 0;
}
index++;
}
}
return 0;
}
+3 -2
View File
@@ -1,8 +1,9 @@
/* eslint-disable react/display-name */
import { ComponentType, useEffect } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import useAliases from '../common/hooks-query/useAliases';
import { getAliasRoute } from '../common/utils/aliases';
import { ComponentType, useEffect } from 'react';
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
const withAlias = <P extends object>(Component: ComponentType<P>) => {
return (props: Partial<P>) => {
@@ -77,6 +77,13 @@ function MakeUserField({ getValue, row: { index }, column: { id }, table }: Cell
export function makeCuesheetColumns(userFields?: UserFields): ColumnDef<OntimeRundownEntry>[] {
return [
{
accessorKey: 'cue',
id: 'cue',
header: 'Cue',
cell: (row) => row.getValue(),
size: 75,
},
{
accessorKey: 'isPublic',
id: 'isPublic',
@@ -5,6 +5,7 @@ import { OntimeEntryCommonKeys, OntimeEvent } from 'ontime-types';
*/
export const defaultColumnOrder: OntimeEntryCommonKeys[] = [
'isPublic',
'cue',
'timeStart',
'timeEnd',
'duration',
@@ -215,6 +215,11 @@ $playback-width: 26rem;
flex-direction: column;
}
.mainContainer > .rundown {
padding: 1rem 0;
}
.content {
padding-top: 1.5rem;
}
@@ -6,10 +6,14 @@
gap: max(1rem, 2vh);
display: grid;
grid-template-areas:
'eventInfo eventActions'
'timeOptions titles';
grid-template-columns: auto 1fr;
grid-template-areas: 'time left right';
grid-template-columns: auto 1fr 1fr;
}
.timeOptions {
grid-area: time;
display: flex;
gap: 1.5rem;
.timers,
.timeSettings {
@@ -19,51 +23,23 @@
}
}
.eventInfo {
grid-area: eventInfo;
.left,
.right {
display: flex;
align-items: center;
.eventId {
margin-left:$element-spacing;
}
flex-direction: column;
gap: 0.5rem;
}
.eventActions {
grid-area: eventActions;
margin-left: auto;
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.timeOptions {
grid-area: timeOptions;
display: flex;
gap: 1.5rem;
}
.titles {
grid-area: titles;
display: grid;
grid-template-areas: 'left right';
grid-template-columns: 1fr 1fr;
.left,
.right {
display: flex;
flex-direction: column;
gap: 8px;
}
.left {
grid-area: left;
padding: 0 1rem;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
.right {
padding-left: 1rem;
grid-area: right;
border-left: 1px solid $border-color-ondark;
}
@mixin input-label() {
@@ -94,6 +70,12 @@
}
}
.eventActions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.spacer {
height: 1.25rem;
}
@@ -104,6 +86,12 @@
gap: 1rem;
}
.splitTwo {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.column {
display: flex;
flex-direction: column;
@@ -116,4 +104,4 @@
.fullHeight {
height: 100%
}
}
@@ -1,38 +1,45 @@
import { useEffect, useState } from 'react';
import { OntimeEvent } from 'ontime-types';
import { useCallback, useEffect, useState } from 'react';
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import CopyTag from '../../common/components/copy-tag/CopyTag';
import { useEventAction } from '../../common/hooks/useEventAction';
import useRundown from '../../common/hooks-query/useRundown';
import { useAppMode } from '../../common/stores/appModeStore';
import getDelayTo from '../../common/utils/getDelayTo';
import EventEditorDataLeft from './composite/EventEditorDataLeft';
import EventEditorDataRight from './composite/EventEditorDataRight';
import EventEditorTimes from './composite/EventEditorTimes';
import EventEditorTitles from './composite/EventEditorTitles';
import style from './EventEditor.module.scss';
export type EventEditorSubmitActions = keyof OntimeEvent;
export type EditorUpdateFields = 'cue' | 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
export default function EventEditor() {
const openId = useAppMode((state) => state.editId);
const { data } = useRundown();
const { updateEvent } = useEventAction();
const [event, setEvent] = useState<OntimeEvent | null>(null);
const [delay, setDelay] = useState(0);
useEffect(() => {
if (!data || !openId) {
setEvent(null);
return;
}
const eventIndex = data.findIndex((event) => event.id === openId);
if (eventIndex > -1) {
const event = data[eventIndex];
if (event.type === 'event') {
setDelay(getDelayTo(data, eventIndex));
setEvent(data[eventIndex] as OntimeEvent);
}
const event = data.find((event) => event.id === openId);
if (event && event.type === SupportedEvent.Event) {
setEvent(event as OntimeEvent);
}
}, [data, event, openId]);
}, [data, openId]);
const handleSubmit = useCallback(
(field: EditorUpdateFields, value: string) => {
updateEvent({ id: event?.id, [field]: value });
},
[event?.id, updateEvent],
);
if (!event) {
return <span>Loading...</span>;
@@ -40,34 +47,35 @@ export default function EventEditor() {
return (
<div className={style.eventEditor}>
<div className={style.eventInfo}>
Event ID
<span className={style.eventId}>
<CopyTag label={event.id}>{event.id}</CopyTag>
</span>
</div>
<div className={style.eventActions}>
<CopyTag label='OSC trigger'>{`/ontime/gotoid/${event.id}`}</CopyTag>
</div>
<EventEditorTimes
eventId={event.id}
timeStart={event.timeStart}
timeEnd={event.timeEnd}
duration={event.duration}
delay={delay}
delay={event.delay ?? 0}
isPublic={event.isPublic}
endAction={event.endAction}
timerType={event.timerType}
/>
<EventEditorTitles
key={event.id}
<EventEditorDataLeft
key={`${event.id}-left`}
eventId={event.id}
cue={event.cue}
title={event.title}
presenter={event.presenter}
subtitle={event.subtitle}
handleSubmit={handleSubmit}
/>
<EventEditorDataRight
key={`${event.id}-right`}
note={event.note}
colour={event.colour}
/>
handleSubmit={handleSubmit}
>
<CopyTag label='Event ID'>{event.id}</CopyTag>
<CopyTag label='OSC trigger by id'>{`/ontime/gotoid/${event.id}`}</CopyTag>
<CopyTag label='OSC trigger by cue'>{`/ontime/gotocue/${event.cue}`}</CopyTag>
</EventEditorDataRight>
</div>
);
}
@@ -3,7 +3,7 @@ import { Textarea } from '@chakra-ui/react';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { TitleActions } from './EventEditorDataLeft';
import style from '../EventEditor.module.scss';
@@ -24,7 +24,9 @@ export default function CountedTextArea(props: CountedTextAreaProps) {
return (
<div className={`${style.column} ${style.fullHeight}`}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Textarea
@@ -1,23 +1,26 @@
import { useCallback } from 'react';
import { Input } from '@chakra-ui/react';
import { Input, InputProps } from '@chakra-ui/react';
import { sanitiseCue } from 'ontime-utils';
import useReactiveTextInput from '../../../common/components/input/text-input/useReactiveTextInput';
import { TitleActions } from './EventEditorTitles';
import { EditorUpdateFields } from '../EventEditor';
import style from '../EventEditor.module.scss';
interface CountedTextInputProps {
field: TitleActions;
interface CountedTextInputProps extends InputProps {
field: EditorUpdateFields;
label: string;
initialValue: string;
submitHandler: (field: TitleActions, value: string) => void;
submitHandler: (field: EditorUpdateFields, value: string) => void;
}
export default function CountedTextInput(props: CountedTextInputProps) {
const { field, label, initialValue, submitHandler } = props;
const { field, label, initialValue, submitHandler, maxLength } = props;
const submitCallback = useCallback((newValue: string) => submitHandler(field, newValue), [field, submitHandler]);
const submitCallback = useCallback(
(newValue: string) => submitHandler(field, sanitiseCue(newValue)),
[field, submitHandler],
);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, {
submitOnEnter: true,
@@ -26,7 +29,9 @@ export default function CountedTextInput(props: CountedTextInputProps) {
return (
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor={field}>{label}</label>
<label className={style.inputLabel} htmlFor={field}>
{label}
</label>
<span className={style.charCount}>{`${value.length} characters`}</span>
</div>
<Input
@@ -35,6 +40,7 @@ export default function CountedTextInput(props: CountedTextInputProps) {
variant='ontime-filled'
data-testid='input-textfield'
value={value}
maxLength={maxLength || 50}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
@@ -0,0 +1,49 @@
import { memo } from 'react';
import { Input } from '@chakra-ui/react';
import { type EditorUpdateFields } from '../EventEditor';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorLeftProps {
eventId: string;
cue: string;
title: string;
presenter: string;
subtitle: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataLeft = (props: EventEditorLeftProps) => {
const { eventId, cue, title, presenter, subtitle, handleSubmit } = props;
return (
<div className={style.left}>
<div className={style.splitTwo}>
<div className={style.column}>
<div className={style.countedInput}>
<label className={style.inputLabel} htmlFor='eventId'>
Event ID (read only)
</label>
</div>
<Input
id='eventId'
size='sm'
variant='ontime-filled'
data-testid='input-textfield'
value={eventId}
readOnly
/>
</div>
<CountedTextInput field='cue' label='Cue' initialValue={cue} submitHandler={handleSubmit} maxLength={10} />
</div>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
);
};
export default memo(EventEditorDataLeft);
@@ -0,0 +1,33 @@
import { memo, PropsWithChildren } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { EditorUpdateFields } from '../EventEditor';
import CountedTextArea from './CountedTextArea';
import style from '../EventEditor.module.scss';
interface EventEditorRightProps {
note: string;
colour: string;
handleSubmit: (field: EditorUpdateFields, value: string) => void;
}
const EventEditorDataRight = (props: PropsWithChildren<EventEditorRightProps>) => {
const { children, note, colour, handleSubmit } = props;
return (
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
<div className={style.eventActions}>{children}</div>
</div>
);
};
export default memo(EventEditorDataRight);
@@ -1,50 +0,0 @@
import { memo } from 'react';
import SwatchSelect from '../../../common/components/input/colour-input/SwatchSelect';
import { useEventAction } from '../../../common/hooks/useEventAction';
import CountedTextArea from './CountedTextArea';
import CountedTextInput from './CountedTextInput';
import style from '../EventEditor.module.scss';
interface EventEditorTitlesProps {
eventId: string;
title: string;
presenter: string;
subtitle: string;
note: string;
colour: string;
}
export type TitleActions = 'title' | 'presenter' | 'subtitle' | 'note' | 'colour';
const EventEditorTitles = (props: EventEditorTitlesProps) => {
const { eventId, title, presenter, subtitle, note, colour } = props;
const { updateEvent } = useEventAction();
const handleSubmit = (field: TitleActions, value: string) => {
updateEvent({ id: eventId, [field]: value });
};
return (
<div className={style.titles}>
<div className={style.left}>
<CountedTextInput field='title' label='Title' initialValue={title} submitHandler={handleSubmit} />
<CountedTextInput field='presenter' label='Presenter' initialValue={presenter} submitHandler={handleSubmit} />
<CountedTextInput field='subtitle' label='Subtitle' initialValue={subtitle} submitHandler={handleSubmit} />
</div>
<div className={style.right}>
<div className={style.column}>
<label className={style.inputLabel}>Colour</label>
<div className={style.inline}>
<SwatchSelect name='colour' value={colour} handleChange={handleSubmit} />
</div>
</div>
<CountedTextArea field='note' label='Note' initialValue={note} submitHandler={handleSubmit} />
</div>
</div>
);
};
export default memo(EventEditorTitles);
@@ -1,4 +1,4 @@
.headerButtons {
text-align: right;
padding-top: 24px;
padding: 1.5rem 1rem 0 1rem;
}
@@ -1,8 +1,12 @@
@use '../../theme/v2Styles' as *;
.eventContainer {
flex: 1;
margin-top: 1em;
display: flex;
flex-direction: column;
padding: 8px 4px 8px 0;
padding: 0 4px;
overflow-y: scroll;
-ms-overflow-style: -ms-autohiding-scrollbar;
height: 100%;
@@ -22,11 +26,29 @@
.alignCenter {
text-align: center;
flex-direction: column;
.spaceTop {
margin-top: 24px;
margin-top: 1.5rem;
}
}
.spacer {
min-height: 50vh;
}
}
.entryWrapper {
display: flex;
gap: 0.5rem;
align-items: center;
}
.entryIndex {
text-align: right;
min-width: 2em;
color: $label-gray;
font-size: calc(1rem - 3px);
}
.entry {
flex: 1;
}
+28 -24
View File
@@ -1,13 +1,14 @@
import { lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { Fragment, lazy, MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
import { closestCenter, DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { OntimeRundown, Playback, SupportedEvent } from 'ontime-types';
import { getFirst, getNext, getPrevious } from 'ontime-utils';
import { useEventAction } from '../../common/hooks/useEventAction';
import { useRundownEditor } from '../../common/hooks/useSocket';
import { AppMode, useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { cloneEvent, getFirstEvent, getNextEvent, getPreviousEvent } from '../../common/utils/eventsManager';
import { cloneEvent } from '../../common/utils/eventsManager';
import QuickAddBlock from './quick-add-block/QuickAddBlock';
import RundownEmpty from './RundownEmpty';
@@ -59,8 +60,7 @@ export default function Rundown(props: RundownProps) {
if (type === 'clone') {
const cursorEvent = entries.find((event) => event.id === cursor);
if (cursorEvent?.type === SupportedEvent.Event) {
const newEvent = cloneEvent(cursorEvent);
newEvent.after = cursorEvent.id;
const newEvent = cloneEvent(cursorEvent, cursorEvent.id);
addEvent(newEvent);
}
} else if (type === SupportedEvent.Event) {
@@ -93,7 +93,7 @@ export default function Rundown(props: RundownProps) {
if (entries.length < 1) {
return;
}
const nextEvent = cursor == null ? getFirstEvent(entries) : getNextEvent(entries, cursor);
const nextEvent = cursor == null ? getFirst(entries) : getNext(entries, cursor);
if (nextEvent) {
moveCursorTo(nextEvent.id, nextEvent.type === SupportedEvent.Event);
}
@@ -104,7 +104,7 @@ export default function Rundown(props: RundownProps) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we check for this before
const previousEvent = cursor == null ? getFirstEvent(entries) : getPreviousEvent(entries, cursor);
const previousEvent = cursor == null ? getFirst(entries) : getPrevious(entries, cursor);
if (previousEvent) {
moveCursorTo(previousEvent.id, previousEvent.type === SupportedEvent.Event);
}
@@ -206,7 +206,7 @@ export default function Rundown(props: RundownProps) {
let previousEnd = 0;
let thisEnd = 0;
let previousEventId: string | undefined;
let eventIndex = -1;
let eventIndex = 0;
let isPast = Boolean(featureData?.selectedEventId);
return (
@@ -216,7 +216,7 @@ export default function Rundown(props: RundownProps) {
<div className={style.list}>
{statefulEntries.map((entry, index) => {
if (index === 0) {
eventIndex = -1;
eventIndex = 0;
}
if (entry.type === SupportedEvent.Event) {
eventIndex++;
@@ -233,21 +233,25 @@ export default function Rundown(props: RundownProps) {
}
return (
<div key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
eventIndex={eventIndex}
isPast={isPast}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
<Fragment key={entry.id}>
<div className={style.entryWrapper}>
{entry.type === SupportedEvent.Event && <div className={style.entryIndex}>{eventIndex}</div>}
<div className={style.entry} key={entry.id} ref={hasCursor ? cursorRef : undefined}>
<RundownEntry
type={entry.type}
isPast={isPast}
data={entry}
selected={isSelected}
hasCursor={hasCursor}
next={isNext}
previousEnd={previousEnd}
previousEventId={previousEventId}
playback={isSelected ? featureData.playback : undefined}
isRolling={featureData.playback === Playback.Roll}
disableEdit={isExtracted}
/>
</div>
</div>
{((showQuickEntry && hasCursor) || isLast) && (
<QuickAddBlock
showKbd={hasCursor}
@@ -257,7 +261,7 @@ export default function Rundown(props: RundownProps) {
disableAddBlock={entry.type === SupportedEvent.Block}
/>
)}
</div>
</Fragment>
);
})}
<div className={style.spacer} />
@@ -1,8 +1,10 @@
import { useCallback } from 'react';
import { OntimeEvent, OntimeRundownEntry, Playback, SupportedEvent } from 'ontime-types';
import { calculateDuration } from 'ontime-utils';
import { calculateDuration, getCueCandidate } from 'ontime-utils';
import { RUNDOWN_TABLE } from '../../common/api/apiConstants';
import { useEventAction } from '../../common/hooks/useEventAction';
import { ontimeQueryClient } from '../../common/queryClient';
import { useAppMode } from '../../common/stores/appModeStore';
import { useEditorSettings } from '../../common/stores/editorSettings';
import { useEmitLog } from '../../common/stores/logger';
@@ -16,7 +18,6 @@ export type EventItemActions = 'set-cursor' | 'event' | 'delay' | 'block' | 'del
interface RundownEntryProps {
type: SupportedEvent;
eventIndex: number;
isPast: boolean;
data: OntimeRundownEntry;
selected: boolean;
@@ -30,19 +31,8 @@ interface RundownEntryProps {
}
export default function RundownEntry(props: RundownEntryProps) {
const {
eventIndex,
isPast,
data,
selected,
hasCursor,
next,
previousEnd,
previousEventId,
playback,
isRolling,
disableEdit,
} = props;
const { isPast, data, selected, hasCursor, next, previousEnd, previousEventId, playback, isRolling, disableEdit } =
props;
const { emitError } = useEmitLog();
const { addEvent, updateEvent, deleteEvent, swapEvents } = useEventAction();
@@ -110,6 +100,7 @@ export default function RundownEntry(props: RundownEntryProps) {
}
case 'clone': {
const newEvent = cloneEvent(data as OntimeEvent, data.id);
newEvent.cue = getCueCandidate(ontimeQueryClient.getQueryData(RUNDOWN_TABLE) || [], data.id);
addEvent(newEvent);
break;
}
@@ -162,10 +153,10 @@ export default function RundownEntry(props: RundownEntryProps) {
if (data.type === SupportedEvent.Event) {
return (
<EventBlock
cue={data.cue}
timeStart={data.timeStart}
timeEnd={data.timeEnd}
duration={data.duration}
eventIndex={eventIndex + 1}
eventId={data.id}
isPublic={data.isPublic}
endAction={data.endAction}
@@ -90,6 +90,18 @@ $skip-opacity: 0.1;
position: absolute;
margin-top: 0.25rem;
}
.cue {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 5.5rem;
text-align: center;
font-weight: 600;
letter-spacing: 0.5px;
rotate: -90deg;
}
}
.playbackActions {
@@ -20,10 +20,10 @@ import EventBlockInner from './EventBlockInner';
import style from './EventBlock.module.scss';
interface EventBlockProps {
cue: string;
timeStart: number;
timeEnd: number;
duration: number;
eventIndex: number;
eventId: string;
isPublic: boolean;
endAction: EndAction;
@@ -54,11 +54,11 @@ interface EventBlockProps {
export default function EventBlock(props: EventBlockProps) {
const {
eventId,
cue,
timeStart,
timeEnd,
duration,
eventIndex,
eventId,
isPublic = true,
endAction,
timerType,
@@ -187,7 +187,7 @@ export default function EventBlock(props: EventBlockProps) {
<span className={style.drag} ref={handleRef} {...dragAttributes} {...dragListeners}>
<IoReorderTwo />
</span>
{eventIndex}
<span className={style.cue}>{cue}</span>
</div>
{isVisible && (
<EventBlockInner
@@ -4,26 +4,27 @@
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
margin: 4px 0;
font-size: 12px;
padding: 0 10px;
margin: 0.25rem 0;
font-size: $inner-section-text-size;
padding: 0 0.75rem;
gap: 1rem;
}
.btnRow {
justify-self: center;
display: flex;
gap: 10%;
gap: max(0.5rem, 2rem);
.quickBtn {
font-weight: 400;
width: auto;
padding: 0 32px;
padding: 0 1rem;
}
}
.keyboard {
margin-left: 8px;
padding: 0 4px;
margin-left: 0.5rem;
padding: 0 0.25rem;
color: $label-gray;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.1);
@@ -32,4 +33,5 @@
.options {
display: flex;
flex-direction: column;
white-space: nowrap;
}
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import QRCode from 'react-qr-code';
import { AnimatePresence, motion } from 'framer-motion';
import { EventData, Message, OntimeEvent, ViewSettings } from 'ontime-types';
import { EventData, Message, OntimeEvent, SupportedEvent, ViewSettings } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -15,7 +15,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { getEventsWithDelay } from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
import { titleVariants } from '../common/animation';
@@ -74,7 +73,7 @@ export default function Backstage(props: BackstageProps) {
: formatTime(time.expectedFinish, formatOptions);
const qrSize = Math.max(window.innerWidth / 15, 128);
const filteredEvents = getEventsWithDelay(backstageEvents);
const filteredEvents = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const showPublicMessage = publ.text && publ.visible;
const showProgress = time.playback !== 'stop';
@@ -9,7 +9,6 @@ import { TIME_FORMAT_OPTION } from '../../../common/components/view-params-edito
import ViewParamsEditor from '../../../common/components/view-params-editor/ViewParamsEditor';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import getDelayTo from '../../../common/utils/getDelayTo';
import { formatTime } from '../../../common/utils/time';
import { useTranslation } from '../../../translation/TranslationProvider';
@@ -72,7 +71,7 @@ export default function Countdown(props: CountdownProps) {
if (followThis !== null) {
setFollow(followThis);
const idx: number = backstageEvents.findIndex((event: OntimeRundownEntry) => event.id === followThis?.id);
const delayToEvent = getDelayTo(backstageEvents, idx);
const delayToEvent = backstageEvents[idx]?.delay ?? 0;
setDelay(delayToEvent);
}
}, [backstageEvents, searchParams]);
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { OntimeRundown, ViewSettings } from 'ontime-types';
import type { OntimeEvent, OntimeRundown, ViewSettings } from 'ontime-types';
import { SupportedEvent } from 'ontime-types';
import { formatDisplay } from 'ontime-utils';
import { overrideStylesURL } from '../../../common/api/apiConstants';
@@ -11,15 +12,11 @@ import useFitText from '../../../common/hooks/useFitText';
import { useRuntimeStylesheet } from '../../../common/hooks/useRuntimeStylesheet';
import { TimeManagerType } from '../../../common/models/TimeManager.type';
import { secondsInMillis } from '../../../common/utils/dateConfig';
import {
formatEventList,
getEventsWithDelay,
type ScheduleEvent,
trimRundown,
} from '../../../common/utils/eventsManager';
import { formatTime } from '../../../common/utils/time';
import { TitleManager } from '../ViewWrapper';
import { type ScheduleEvent, formatEventList, trimRundown } from './studioClock.utils';
import './StudioClock.scss';
const formatOptions = {
@@ -66,8 +63,8 @@ export default function StudioClock(props: StudioClockProps) {
return;
}
const delayed = getEventsWithDelay(backstageEvents);
const trimmed = trimRundown(delayed, selectedId || '', MAX_TITLES);
const delayed = backstageEvents.filter((event) => event.type === SupportedEvent.Event);
const trimmed = trimRundown(delayed as OntimeEvent[], selectedId || '', MAX_TITLES);
const formatted = formatEventList(trimmed, selectedId || '', nextId || '', {
showEnd: false,
@@ -0,0 +1,203 @@
import { OntimeEvent, SupportedEvent } from 'ontime-types';
import { formatEventList, trimRundown } from '../studioClock.utils';
describe('test trimEventlist function', () => {
const limit = 8;
const testData = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
{ id: '10' },
{ id: '11' },
{ id: '12' },
];
it('when we use the first item', () => {
const selectedId = '1';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the third item', () => {
const selectedId = '3';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('when we use the fourth item', () => {
const selectedId = '4';
const expected = [
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
{ id: '9' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
it('if selected is not found', () => {
const selectedId = '15';
const expected = [
{ id: '1' },
{ id: '2' },
{ id: '3' },
{ id: '4' },
{ id: '5' },
{ id: '6' },
{ id: '7' },
{ id: '8' },
];
const l = trimRundown(testData as OntimeEvent[], selectedId, limit);
expect(l.length).toBe(limit);
expect(l).toStrictEqual(expected);
});
});
describe('test formatEvents function', () => {
const testEvent = [
{
title: 'Welcome to Ontime',
subtitle: 'Subtitles are useful',
presenter: 'cpvalente',
note: 'Maybe a running note for the operator?',
timeStart: 28800000,
timeEnd: 30600000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '5946',
},
{
title: 'Unless recalled by the OSC address',
subtitle: '',
presenter: '',
note: 'In green, below',
timeStart: 34800000,
timeEnd: 35400000,
isPublic: false,
colour: '',
type: SupportedEvent.Event,
revision: 0,
id: '8ee5',
},
];
it('it parses correctly', () => {
const selectedId = 'otherEvent';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles selected correctly', () => {
const selectedId = '5946';
const nextId = '8ee5';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: true,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: false,
isNext: true,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
it('it handles next correctly', () => {
const selectedId = '8ee5';
const nextId = 'notHere';
const expected = [
{
id: '5946',
time: '08:00 - 08:30',
title: 'Welcome to Ontime',
isNow: false,
isNext: false,
colour: '',
},
{
id: '8ee5',
time: '09:40 - 09:50',
title: 'Unless recalled by the OSC address',
isNow: true,
isNext: false,
colour: '',
},
];
const parsed = formatEventList(testEvent as OntimeEvent[], selectedId, nextId, { showEnd: true });
expect(parsed).toStrictEqual(expected);
});
});
@@ -0,0 +1,76 @@
import { OntimeEvent } from 'ontime-types';
import { formatTime } from '../../../common/utils/time';
export type ScheduleEvent = {
id: string;
time: string;
title: string;
isNow: boolean;
isNext: boolean;
colour: string;
};
/**
* @description Returns trimmed event list array
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {number} limit - max number of events to return
* @returns {Object[]} Event list with maximum <limit> objects
*/
export const trimRundown = (rundown: OntimeEvent[], selectedId: string, limit: number): OntimeEvent[] => {
if (rundown == null) return [];
const BEFORE = 2;
const trimmedRundown = [...rundown];
// limit events length if necessary
if (limit != null) {
while (trimmedRundown.length > limit) {
const idx = trimmedRundown.findIndex((e) => e.id === selectedId);
if (idx <= BEFORE) {
trimmedRundown.pop();
} else {
trimmedRundown.shift();
}
}
}
return trimmedRundown;
};
type FormatEventListOptionsProp = {
showEnd?: boolean;
};
/**
* @description Returns list of events formatted to be displayed
* @param {Object[]} rundown - given rundown
* @param {string} selectedId - id of currently selected event
* @param {string} nextId - id of next event
* @param {object} [options]
* @param {boolean} [options.showEnd] - whether to show the end time
* @returns {Object[]} Formatted list of events [{time: -, title: -, isNow, isNext}]
*/
export const formatEventList = (
rundown: OntimeEvent[],
selectedId: string,
nextId: string,
options: FormatEventListOptionsProp,
): ScheduleEvent[] => {
if (rundown == null) return [];
const { showEnd = false } = options;
// format list
return rundown.map((event) => {
const start = formatTime(event.timeStart + (event.delay || 0));
const end = formatTime(event.timeEnd + (event.delay || 0));
return {
id: event.id,
time: showEnd ? `${start} - ${end}` : start,
title: event.title,
isNow: event.id === selectedId,
isNext: event.id === nextId,
colour: event.colour,
};
});
};
@@ -2,7 +2,7 @@
* Class Event Provider is a mediator for handling the local db
* and adds logic specific to ontime data
*/
import { EventData, ViewSettings } from 'ontime-types';
import { EventData, OntimeRundown, ViewSettings } from 'ontime-types';
import { data, db } from '../../modules/loadDb.js';
import { safeMerge } from './DataProvider.utils.js';
@@ -22,19 +22,15 @@ export class DataProvider {
return data.eventData;
}
static async setRundown(newData) {
static async setRundown(newData: OntimeRundown) {
data.rundown = [...newData];
await this.persist();
}
static getIndexOf(eventId) {
static getIndexOf(eventId: string) {
return data.rundown.findIndex((e) => e.id === eventId);
}
static getEventById(eventId) {
return data.rundown.find((e) => e.id === eventId);
}
static getRundownLength() {
return data.rundown.length;
}
@@ -85,6 +85,16 @@ export class EventLoader {
return timedEvents.find((event) => event.id === eventId);
}
/**
* returns first event given its cue
* @param {string} cue
* @return {object | undefined}
*/
static getEventWithCue(cue) {
const timedEvents = EventLoader.getTimedEvents();
return timedEvents.find((event) => event.cue === cue);
}
/**
* loads an event given its id
* @param {string} eventId
@@ -123,6 +123,15 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
PlaybackService.startById(payload);
break;
}
case 'startcue': {
if (!payload || typeof payload !== 'string') {
throw new Error(`Event cue not recognised: ${payload}`);
}
PlaybackService.startByCue(payload);
break;
}
case 'pause': {
PlaybackService.pause();
break;
@@ -189,6 +198,19 @@ export function dispatchFromAdapter(type: string, payload: unknown, source?: 'os
}
break;
}
case 'gotocue':
case 'loadcue': {
if (!payload || typeof payload !== 'string') {
throw new Error(`Event cue not recognised: ${payload}`);
}
try {
PlaybackService.loadByCue(payload);
} catch (error) {
throw new Error(`OSC IN: error calling goto ${error}`);
}
break;
}
case 'get-playback': {
const playback = eventStore.get('playback');
+1 -1
View File
@@ -1,6 +1,6 @@
import { EndAction, OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent, TimerType } from 'ontime-types';
export const event: Omit<OntimeEvent, 'id' | 'delay'> = {
export const event: Omit<OntimeEvent, 'id' | 'delay' | 'cue'> = {
title: '',
subtitle: '',
presenter: '',
@@ -62,6 +62,21 @@ export class PlaybackService {
return success;
}
/**
* starts first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static startByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
PlaybackService.start();
}
return success;
}
/**
* loads event matching given ID
* @param {string} eventId
@@ -90,6 +105,20 @@ export class PlaybackService {
return success;
}
/**
* loads first event matching given cue
* @param {string} cue
* @return {boolean} success
*/
static loadByCue(cue: string): boolean {
const event = EventLoader.getEventWithCue(cue);
const success = PlaybackService.loadEvent(event);
if (success) {
logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`);
}
return success;
}
/**
* Loads event before currently selected
*/
@@ -8,9 +8,9 @@ import {
Playback,
SupportedEvent,
} from 'ontime-types';
import { generateId } from 'ontime-utils';
import { generateId, getCueCandidate } from 'ontime-utils';
import { DataProvider } from '../../classes/data-provider/DataProvider.js';
import { block as blockDef, delay as delayDef, event as eventDef } from '../../models/eventsDefinition.js';
import { block as blockDef, delay as delayDef } from '../../models/eventsDefinition.js';
import { MAX_EVENTS } from '../../settings.js';
import { EventLoader, eventLoader } from '../../classes/event-loader/EventLoader.js';
import { eventTimer } from '../TimerService.js';
@@ -26,6 +26,7 @@ import {
delayedRundownCacheKey,
} from './delayedRundown.utils.js';
import { logger } from '../../classes/Logger.js';
import { validateEvent } from '../../utils/parser.js';
import { clock } from '../Clock.js';
/**
@@ -164,30 +165,30 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
let newEvent: Partial<OntimeBaseEvent> = {};
const id = generateId();
// TODO: filter the parameters that exist in the event, use the parserUtils
switch (eventData.type) {
case SupportedEvent.Event:
newEvent = { ...eventDef, ...eventData, id };
break;
case SupportedEvent.Delay:
newEvent = { ...delayDef, ...eventData, id };
break;
case SupportedEvent.Block:
newEvent = { ...blockDef, ...eventData, id };
break;
}
let insertIndex = 0;
if (typeof newEvent?.after !== 'undefined') {
const index = DataProvider.getIndexOf(newEvent.after);
if (eventData?.after !== 'undefined') {
const index = DataProvider.getIndexOf(eventData.after);
if (index < 0) {
logger.warning(LogOrigin.Server, `Could not find event with id ${newEvent.after}`);
logger.warning(LogOrigin.Server, `Could not find event with id ${eventData.after}`);
} else {
insertIndex = index + 1;
}
delete newEvent.after;
}
switch (eventData.type) {
case SupportedEvent.Event: {
newEvent = validateEvent(eventData, getCueCandidate(DataProvider.getRundown(), eventData?.after)) as OntimeEvent;
break;
}
case SupportedEvent.Delay:
newEvent = { ...delayDef, duration: eventData.duration, id } as OntimeDelay;
break;
case SupportedEvent.Block:
newEvent = { ...blockDef, title: eventData.title, id } as OntimeBlock;
break;
}
delete eventData.after;
// modify rundown
await cachedAdd(insertIndex, newEvent as OntimeEvent | OntimeDelay | OntimeBlock);
@@ -204,6 +205,10 @@ export async function addEvent(eventData: Partial<OntimeEvent> | Partial<OntimeD
}
export async function editEvent(eventData: Partial<OntimeEvent> | Partial<OntimeBlock> | Partial<OntimeDelay>) {
if (eventData.type === SupportedEvent.Event && eventData?.cue === '') {
throw new Error(`Cue value invalid`);
}
const newEvent = await cachedEdit(eventData.id, eventData);
// notify timer service of changed events
@@ -1,4 +1,4 @@
import { EndAction, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { EndAction, OntimeEvent, OntimeRundown, SupportedEvent, TimerType } from 'ontime-types';
import { calculateRuntimeDelays, calculateRuntimeDelaysFrom, getDelayAt } from '../delayedRundown.utils.js';
@@ -31,6 +31,7 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
id: '659e1',
cue: '1',
},
{
duration: 600000,
@@ -64,6 +65,7 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
id: '1c48f',
cue: '2',
},
{
duration: 1200000,
@@ -97,6 +99,7 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
id: 'd48c2',
cue: '3',
},
{
title: '',
@@ -129,16 +132,17 @@ describe('calculateRuntimeDelays', () => {
type: SupportedEvent.Event,
revision: 0,
id: '2f185',
cue: '4',
},
];
const updatedRundown = calculateRuntimeDelays(rundown);
expect(rundown.length).toBe(updatedRundown.length);
expect(updatedRundown[0].delay).toBe(0);
expect(updatedRundown[2].delay).toBe(600000);
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
expect(updatedRundown[6].delay).toBe(0);
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
expect((updatedRundown[2] as OntimeEvent).delay).toBe(600000);
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
expect((updatedRundown[6] as OntimeEvent).delay).toBe(0);
});
});
@@ -171,6 +175,7 @@ describe('getDelayAt()', () => {
revision: 0,
id: '659e1',
delay: 0,
cue: '1',
},
{
duration: 600000,
@@ -205,6 +210,7 @@ describe('getDelayAt()', () => {
revision: 0,
id: '1c48f',
delay: 600000,
cue: '2',
},
{
duration: 1200000,
@@ -239,6 +245,7 @@ describe('getDelayAt()', () => {
revision: 0,
id: 'd48c2',
delay: 1800000,
cue: '3',
},
{
title: '',
@@ -272,6 +279,7 @@ describe('getDelayAt()', () => {
revision: 0,
id: '2f185',
delay: 0,
cue: '4',
},
];
@@ -331,6 +339,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
revision: 0,
id: '659e1',
delay: 0,
cue: '1',
},
{
duration: 600000,
@@ -365,6 +374,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
revision: 0,
id: '1c48f',
delay: 0,
cue: '2',
},
{
duration: 1200000,
@@ -399,6 +409,7 @@ describe('calculateRuntimeDelaysFrom()', () => {
revision: 0,
id: 'd48c2',
delay: 1800000,
cue: '3',
},
{
title: '',
@@ -432,14 +443,15 @@ describe('calculateRuntimeDelaysFrom()', () => {
revision: 0,
id: '2f185',
delay: 0,
cue: '4',
},
];
const updatedRundown = calculateRuntimeDelaysFrom('07986', delayedRundown);
// we only update from the 4th on
expect(updatedRundown[0].delay).toBe(0);
expect((updatedRundown[0] as OntimeEvent).delay).toBe(0);
// 1 + 3
expect(updatedRundown[4].delay).toBe(600000 + 1200000);
expect((updatedRundown[4] as OntimeEvent).delay).toBe(600000 + 1200000);
});
});
@@ -1,54 +0,0 @@
import { getPreviousPlayable } from '../eventUtils.js';
describe('getPreviousPlayable()', () => {
describe('given a list of events', () => {
it('finds the previous playable event', () => {
const events = [
{ id: 100, type: 'delay' },
{ id: 101, type: 'event', skip: true },
{ id: 102, type: 'event', skip: true },
{ id: 103, type: 'event', skip: false },
{ id: 'not-this', type: 'block' },
{ id: 104, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(3);
expect(id).toBe(103);
});
});
describe('handles common errors', () => {
it('returns null if id not found in list', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: false },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, 'no-valid-id');
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if there are no previous events to play', () => {
const events = [
{ id: 0, type: 'delay' },
{ id: 1, type: 'event', skip: true },
{ id: 2, type: 'event', skip: true },
{ id: 3, type: 'event', skip: true },
{ id: 4, type: 'event' },
];
const { index, id } = getPreviousPlayable(events, events[4].id);
expect(index).toBe(null);
expect(id).toBe(null);
});
it('returns null if list is empty', () => {
const events = [];
const { index, id } = getPreviousPlayable(events, 'made-up');
expect(index).toBe(null);
expect(id).toBe(null);
});
});
});
@@ -1,10 +1,11 @@
import { vi } from 'vitest';
import { dbModel } from '../../models/dataModel.ts';
import { parseExcel, parseJson, validateEvent } from '../parser.ts';
import { makeString } from '../parserUtils.ts';
import { parseAliases, parseUserFields, parseViewSettings } from '../parserFunctions.ts';
import { EndAction, TimerType } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { EndAction, OntimeEvent, TimerType } from 'ontime-types';
import { dbModel } from '../../models/dataModel.js';
import { parseExcel, parseJson, validateEvent } from '../parser.js';
import { makeString } from '../parserUtils.js';
import { parseAliases, parseUserFields, parseViewSettings } from '../parserFunctions.js';
describe('test json parser with valid def', () => {
const testData = {
@@ -220,64 +221,20 @@ describe('test json parser with valid def', () => {
const first = parseResponse?.rundown[0];
const expected = {
title: 'Guest Welcoming',
subtitle: '',
presenter: '',
note: '',
timeStart: 31500000,
timeEnd: 32400000,
duration: 32400000 - 31500000,
isPublic: false,
endAction: 'play-next',
timerType: 'clock',
skip: false,
colour: '',
type: 'event',
revision: 0,
id: '4b31',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(first).toStrictEqual(expected);
expect(first).toMatchObject(expected);
});
it('second event is as a match', () => {
const second = parseResponse?.rundown[1];
const expected = {
title: 'Good Morning',
subtitle: 'Days schedule',
presenter: 'Carlos Valente',
note: '',
timeStart: 32400000,
timeEnd: 36000000,
endAction: 'play-next',
timerType: 'count-up',
duration: 36000000 - 32400000,
isPublic: true,
skip: true,
colour: 'red',
type: 'event',
revision: 0,
id: 'f24d',
user0: '',
user1: '',
user2: '',
user3: '',
user4: '',
user5: '',
user6: '',
user7: '',
user8: '',
user9: '',
};
expect(second).toStrictEqual(expected);
expect(second).toMatchObject(expected);
});
it('third event end action is set as the default value', () => {
const third = parseResponse?.rundown[2];
@@ -448,7 +405,7 @@ describe('test corrupt data', () => {
it('handles missing event data', async () => {
const emptyEventData = {
rundown: [{}, {}, {}, {}, {}, {}, {}, {}],
event: {},
eventData: {},
settings: {
app: 'ontime',
version: 2,
@@ -459,7 +416,7 @@ describe('test corrupt data', () => {
};
const parsedDef = await parseJson(emptyEventData);
expect(parsedDef.event).toStrictEqual(dbModel.event);
expect(parsedDef.eventData).toStrictEqual(dbModel.eventData);
});
it('handles missing settings', async () => {
@@ -488,7 +445,7 @@ describe('test event validator', () => {
const event = {
title: 'test',
};
const validated = validateEvent(event);
const validated = validateEvent(event, 'test');
expect(validated).toEqual(
expect.objectContaining({
@@ -503,6 +460,7 @@ describe('test event validator', () => {
revision: expect.any(Number),
type: expect.any(String),
id: expect.any(String),
cue: 'test',
colour: expect.any(String),
user0: expect.any(String),
user1: expect.any(String),
@@ -520,7 +478,7 @@ describe('test event validator', () => {
it('fails an empty object', () => {
const event = {};
const validated = validateEvent(event);
const validated = validateEvent(event, 'none');
expect(validated).toEqual(null);
});
@@ -531,7 +489,8 @@ describe('test event validator', () => {
presenter: 3.2,
note: '1899-12-30T08:00:10.000Z',
};
const validated = validateEvent(event);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event, 'not-used');
expect(typeof validated.title).toEqual('string');
expect(typeof validated.subtitle).toEqual('string');
expect(typeof validated.presenter).toEqual('string');
@@ -543,6 +502,7 @@ describe('test event validator', () => {
timeStart: false,
timeEnd: '2',
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event);
expect(typeof validated.timeStart).toEqual('number');
expect(validated.timeStart).toEqual(0);
@@ -554,6 +514,7 @@ describe('test event validator', () => {
const event = {
title: {},
};
// @ts-expect-error -- we know this is wrong, testing imports outside domain
const validated = validateEvent(event);
expect(typeof validated.title).toEqual('string');
});
@@ -571,11 +532,13 @@ describe('test makeString function', () => {
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = ['testing'];
expected = 'testing';
converted = makeString(val);
expect(converted).toBe(expected);
// @ts-expect-error -- we know this is wrong, testing imports outside domain
val = { doing: 'testing' };
converted = makeString(val, 'fallback');
expect(converted).toBe('fallback');
@@ -628,10 +591,6 @@ describe('test parseExcel function', () => {
'x',
'',
'Ballyhoo',
'',
'',
'',
'',
'a0',
'a1',
'a2',
@@ -650,15 +609,11 @@ describe('test parseExcel function', () => {
'A song from the hearth',
'Still Carlos',
'Derailing early',
'clock',
'load-next',
'clock',
'',
'',
'x',
'Rainbow chase',
'',
'',
'',
'',
'b0',
'',
'',
@@ -682,10 +637,11 @@ describe('test parseExcel function', () => {
backstageInfo: 'test backstage info',
};
// TODO: update tests once import is resolved
const expectedParsedRundown = [
{
timeStart: 25200000,
timeEnd: 28810000,
//timeStart: 28800000,
//timeEnd: 32410000,
title: 'Guest Welcome',
presenter: 'Carlos',
subtitle: 'Getting things started',
@@ -708,8 +664,8 @@ describe('test parseExcel function', () => {
type: 'event',
},
{
timeStart: 28800000,
timeEnd: 30600000,
//timeStart: 32400000,
//timeEnd: 34200000,
title: 'A song from the hearth',
presenter: 'Still Carlos',
subtitle: 'Derailing early',
@@ -728,13 +684,8 @@ describe('test parseExcel function', () => {
const parsedData = await parseExcel(testdata);
expect(parsedData.eventData).toStrictEqual(expectedParsedEvent);
expect(parsedData.rundown).toBeDefined();
expect(parsedData.rundown.title).toBe(expectedParsedRundown.title);
expect(parsedData.rundown.presenter).toBe(expectedParsedRundown.presenter);
expect(parsedData.rundown.subtitle).toBe(expectedParsedRundown.subtitle);
expect(parsedData.rundown.isPublic).toBe(expectedParsedRundown.isPublic);
expect(parsedData.rundown.skip).toBe(expectedParsedRundown.skip);
expect(parsedData.rundown.note).toBe(expectedParsedRundown.note);
expect(parsedData.rundown.type).toBe(expectedParsedRundown.type);
expect(parsedData.rundown[0]).toMatchObject(expectedParsedRundown[0]);
expect(parsedData.rundown[1]).toMatchObject(expectedParsedRundown[1]);
});
});
@@ -759,7 +710,6 @@ describe('test aliases import', () => {
expect(parsed.length).toBe(1);
// generates missing id
console.log(parsed);
expect(parsed[0].alias).toBeDefined();
});
});
@@ -874,7 +824,7 @@ describe('test views import', () => {
endMessage: '',
overrideStyles: false,
};
const parsed = parseViewSettings(testData);
const parsed = parseViewSettings(testData, false);
expect(parsed).toStrictEqual(expectedParsedViewSettings);
});
@@ -1,28 +0,0 @@
import { parseExcelDate } from '../time';
describe('parseExcelDate', () => {
it('parses a valid date string as expected from excel', () => {
const millis = parseExcelDate('1899-12-30T07:00:00.000Z');
expect(millis).not.toBe(0);
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
@@ -0,0 +1,58 @@
import { parseExcelDate } from '../time.js';
describe('parseExcelDate', () => {
describe.todo('parses a valid date string as expected from excel', () => {
const testCases = [
{
fromExcel: '1899-12-30T00:00:00.000Z',
expected: 3600000,
},
{
fromExcel: '1899-12-30T00:10:00.000Z',
expected: 4200000,
},
{
fromExcel: '1899-12-30T01:00:00.000Z',
expected: 7200000,
},
{
fromExcel: '1899-12-30T07:00:00.000Z',
expected: 28800000,
},
{
fromExcel: '1899-12-30T08:00:10.000Z',
expected: 32410000,
},
{
fromExcel: '1899-12-30T08:30:00.000Z',
expected: 34200000,
},
];
for (const scenario of testCases) {
it(`handles ${scenario.fromExcel}`, () => {
expect(parseExcelDate(scenario.fromExcel)).toBe(scenario.expected);
});
}
});
describe('parses a time string that passes validation', () => {
const validFields = ['10:00:00', '10:00'];
validFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).not.toBe(0);
});
});
});
describe('returns 0 on other strings', () => {
const invalidFields = ['10', 'test', ''];
invalidFields.forEach((field) => {
it(`handles ${field}`, () => {
const millis = parseExcelDate(field);
expect(millis).toBe(0);
});
});
});
});
@@ -1,4 +1,4 @@
import { cleanURL } from '../url';
import { cleanURL } from '../url.js';
describe('url is correctly formatted', () => {
it('has no leading spaces', () => {
-25
View File
@@ -1,25 +0,0 @@
/**
* @description Returns id of previous played event
* @param {array} events
* @param {string} eventId
* @return {object}
*/
export function getPreviousPlayable(events, eventId) {
// find current index
const current = events.findIndex((event) => event.id === eventId);
if (current === -1) {
return { index: null, id: null };
}
let index = current - 1;
while (index >= 0) {
const event = events[index];
if (event.type === 'event' && !event.skip) {
return { index, id: event.id };
}
index--;
}
return { index: null, id: null };
}
+36 -13
View File
@@ -4,7 +4,16 @@
import fs from 'fs';
import xlsx from 'node-xlsx';
import { generateId, calculateDuration } from 'ontime-utils';
import { DatabaseModel, EventData, OntimeEvent, OntimeRundown, UserFields } from 'ontime-types';
import {
DatabaseModel,
EndAction,
EventData,
OntimeEvent,
OntimeRundown,
SupportedEvent,
TimerType,
UserFields,
} from 'ontime-types';
import { event as eventDef } from '../models/eventsDefinition.js';
import { dbModel } from '../models/dataModel.js';
import { deleteFile, makeString } from './parserUtils.js';
@@ -38,6 +47,7 @@ export const parseExcel = async (excelData) => {
let timeStartIndex: number | null = null;
let timeEndIndex: number | null = null;
let titleIndex: number | null = null;
let cueIndex: number | null = null;
let presenterIndex: number | null = null;
let subtitleIndex: number | null = null;
let isPublicIndex: number | null = null;
@@ -91,6 +101,8 @@ export const parseExcel = async (excelData) => {
event.timeEnd = parseExcelDate(column);
} else if (j === titleIndex) {
event.title = column;
} else if (j === cueIndex) {
event.cue = column;
} else if (j === presenterIndex) {
event.presenter = column;
} else if (j === subtitleIndex) {
@@ -102,9 +114,17 @@ export const parseExcel = async (excelData) => {
} else if (j === notesIndex) {
event.note = column;
} else if (j === endActionIndex) {
event.endAction = column;
if (column === '') {
event.endAction = EndAction.None;
} else {
event.endAction = column;
}
} else if (j === timerTypeIndex) {
event.timerType = column;
if (column === '') {
event.timerType = TimerType.CountDown;
} else {
event.timerType = column;
}
} else if (j === colourIndex) {
event.colour = column;
} else if (j === user0Index) {
@@ -130,6 +150,7 @@ export const parseExcel = async (excelData) => {
} else {
if (typeof column === 'string') {
const col = column.toLowerCase();
// look for keywords
// need to make sure it is a string first
switch (col) {
@@ -157,6 +178,10 @@ export const parseExcel = async (excelData) => {
case 'finish':
timeEndIndex = j;
break;
case 'cue':
case 'page':
cueIndex = j;
break;
case 'event title':
case 'title':
titleIndex = j;
@@ -243,7 +268,7 @@ export const parseExcel = async (excelData) => {
if (Object.keys(event).length > 0) {
// if any data was found, push to array
// take care of it in the next step
rundown.push({ ...event, type: 'event' });
rundown.push({ ...event, type: SupportedEvent.Event } as OntimeEvent);
}
});
return {
@@ -284,7 +309,6 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
// Import user fields if any
returnData.userFields = parseUserFields(jsonData);
// Import OSC settings if any
// @ts-expect-error -- we are unable to type just yet
returnData.osc = parseOsc(jsonData, enforce);
// Import HTTP settings if any
// returnData.http = parseHttp(jsonData, enforce);
@@ -295,12 +319,15 @@ export const parseJson = async (jsonData, enforce = false): Promise<DatabaseMode
/**
* @description Enforces formatting for events
* @param {object} eventArgs - attributes of event
* @param cueFallback
* @returns {object|null} - formatted object or null in case is invalid
*/
export const validateEvent = (eventArgs) => {
export const validateEvent = (eventArgs: Partial<OntimeEvent>, cueFallback: string) => {
// ensure id is defined and unique
const id = eventArgs.id || generateId();
const cue = eventArgs.cue || cueFallback;
let event = null;
// return if object is empty
@@ -336,12 +363,9 @@ export const validateEvent = (eventArgs) => {
user7: makeString(e.user7, d.user7),
user8: makeString(e.user8, d.user8),
user9: makeString(e.user9, d.user9),
// deciding not to validate colour
// this adds flexibility to the user to write hex codes, rgb,
// but also colour names like blue and red
// CSS.supports is only available in frontend
colour: makeString(e.colour, d.colour),
id,
cue,
type: 'event',
};
}
@@ -357,7 +381,7 @@ type ResponseError = { error: true; message: string };
* @param {string} file - reference to file
* @return {object} - parse result message
*/
export const fileHandler = async (file): ResponseOK | ResponseError => {
export const fileHandler = async (file): Promise<ResponseOK | ResponseError> => {
let res: Partial<ResponseOK | ResponseError> = {};
// check which file type are we dealing with
@@ -376,8 +400,7 @@ export const fileHandler = async (file): ResponseOK | ResponseError => {
res.data.userFields = parseUserFields(dataFromExcel);
res.message = 'success';
} else {
const errorMessage = 'No sheet found named ontime or event schedule';
console.log(errorMessage);
const errorMessage = 'No sheet found named "ontime" or "event schedule"';
res = {
error: true,
message: errorMessage,
+11 -2
View File
@@ -30,6 +30,7 @@ export const parseRundown = (data): OntimeRundown => {
console.log('Found rundown definition, importing...');
const rundown = [];
try {
let eventIndex = 0;
const ids = [];
for (const e of data.rundown) {
// cap number of events
@@ -43,6 +44,7 @@ export const parseRundown = (data): OntimeRundown => {
console.log('ERROR: ID collision on import, skipping');
continue;
}
// validate the right endAction is used
if (e.endAction && !Object.values(EndAction).includes(e.endAction)) {
e.endAction = EndAction.None;
@@ -54,8 +56,10 @@ export const parseRundown = (data): OntimeRundown => {
e.timerType = TimerType.CountDown;
console.log('WARNING: invalid Timer Type provided, using default');
}
if (e.type === 'event') {
const event = validateEvent(e);
eventIndex += 1;
const event = validateEvent(e, eventIndex.toString());
if (event != null) {
rundown.push(event);
ids.push(event.id);
@@ -216,7 +220,12 @@ export const validateOscObject = (data: OscSubscription): boolean => {
/**
* Parse osc portion of an entry
*/
export const parseOsc = (data: { osc?: Partial<OSCSettings> }, enforce: boolean): Partial<OSCSettings> => {
export const parseOsc = (
data: {
osc?: Partial<OSCSettings>;
},
enforce: boolean,
): OSCSettings | Record<string, never> => {
if ('osc' in data) {
console.log('Found OSC definition, importing...');
-1
View File
@@ -1,5 +1,4 @@
import fs from 'fs';
import { dayInMs } from 'ontime-utils';
/**
* @description Ensures variable is string, it skips object types
+3 -2
View File
@@ -8,12 +8,13 @@ export const timeFormat = 'HH:mm';
export const timeFormatSeconds = 'HH:mm:ss';
/**
* @description Converts an excel date to milliseconds
* @argument {string} date - excel string date
* @description Converts a date object to milliseconds
* @argument {Date} date
* @returns {number} - time in milliseconds
*/
export const dateToMillis = (date: Date): number => {
// TODO: Use UTC
const h = date.getHours();
const m = date.getMinutes();
const s = date.getSeconds();
@@ -26,6 +26,7 @@ export type OntimeBlock = OntimeBaseEvent & {
export type OntimeEvent = OntimeBaseEvent & {
type: SupportedEvent.Event;
cue: string;
title: string;
subtitle: string;
presenter: string;
+28 -53
View File
@@ -1,75 +1,50 @@
import { Alias } from './definitions/core/Alias.type.js';
import { DatabaseModel } from './definitions/DataModel.type.js';
import { EndAction } from './definitions/EndAction.type.js';
import { EventData } from './definitions/core/EventData.type.js';
import { Message, TimerMessage } from './definitions/runtime/MessageControl.type.js';
import {
OntimeBaseEvent,
OntimeBlock,
OntimeDelay,
OntimeEvent,
SupportedEvent,
} from './definitions/core/OntimeEvent.type.js';
import { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js';
import { Playback } from './definitions/runtime/Playback.type.js';
import { Loaded } from './definitions/runtime/Playlist.type.js';
import { Log, LogLevel, LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
import { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
import { Settings } from './definitions/core/Settings.type.js';
import { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
import { TimerState } from './definitions/runtime/TimerState.type.js';
import { TimerType } from './definitions/TimerType.type.js';
import { TitleBlock } from './definitions/runtime/TitleBlock.type.js';
import { UserFields } from './definitions/core/UserFields.type.js';
import { ViewSettings } from './definitions/core/Views.type.js';
import { MaybeNumber } from './utils/utils.type.js';
// DATA MODEL
export type { DatabaseModel };
export type { DatabaseModel } from './definitions/DataModel.type.js';
// ---> Rundown
export { TimerType };
export { EndAction };
export { SupportedEvent };
export type { OntimeBaseEvent, OntimeBlock, OntimeDelay, OntimeEvent };
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry };
export { EndAction } from './definitions/EndAction.type.js';
export {
type OntimeBaseEvent,
type OntimeBlock,
type OntimeDelay,
type OntimeEvent,
SupportedEvent,
} from './definitions/core/OntimeEvent.type.js';
export type { OntimeEntryCommonKeys, OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
export { TimerType } from './definitions/TimerType.type.js';
// ---> Event
export type { EventData };
// ---> Event Data
export type { EventData } from './definitions/core/EventData.type.js';
// ---> Settings
export type { Settings };
export type { Settings } from './definitions/core/Settings.type.js';
// ---> Views
export type { ViewSettings };
export type { ViewSettings } from './definitions/core/Views.type.js';
// ---> Aliases
export type { Alias };
export type { Alias } from './definitions/core/Alias.type.js';
// ---> User Fields
export type { UserFields };
export type { UserFields } from './definitions/core/UserFields.type.js';
// ---> OSC
export type { OscSubscription, OSCSettings, OscSubscriptionOptions };
export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js';
// ---> HTTP
// SERVER RUNTIME
export { LogLevel };
export type { Log, LogMessage };
export { LogOrigin };
export { Playback };
export { TimerLifeCycle };
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
export { Playback } from './definitions/runtime/Playback.type.js';
export { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js';
export type { Message, TimerMessage } from './definitions/runtime/MessageControl.type.js';
export type { Message };
export type { TimerMessage };
export type { Loaded };
export type { RuntimeStore };
export type { TimerState };
export type { TitleBlock };
export type { Loaded } from './definitions/runtime/Playlist.type.js';
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
export type { TimerState } from './definitions/runtime/TimerState.type.js';
export type { TitleBlock } from './definitions/runtime/TitleBlock.type.js';
// CLIENT
// UTILITIES
export type { MaybeNumber };
// UTILITY TYPES
export type { MaybeNumber } from './utils/utils.type.js';
+6
View File
@@ -1,7 +1,10 @@
// runtime utils
export { getFirst, getNext, getPrevious } from './src/rundown-utils/rundownUtils.js';
export { validatePlayback } from './src/validate-action/validatePlayback.js';
// rundown utils
export { sanitiseCue } from './src/cue-utils/cueUtils.js';
export { getCueCandidate } from './src/cue-utils/cueUtils.js';
export { generateId } from './src/generate-id/generateId.js';
export { calculateDuration } from './src/rundown-utils/rundownUtils.js';
export { swapOntimeEvents } from './src/rundown-utils/rundownUtils.js';
@@ -14,3 +17,6 @@ export { millisToString } from './src/date-utils/millisToString.js';
// time utils
export { dayInMs, mts } from './src/timeConstants.js';
// generic utilities
export { isNumeric } from './src/types/types.js';
-1
View File
@@ -2,7 +2,6 @@
"name": "ontime-utils",
"type": "module",
"exports": "./index.ts",
"version": "2.3.9",
"private": true,
"description": "shared logic for ontime",
"scripts": {
@@ -0,0 +1,160 @@
import { OntimeRundown, SupportedEvent } from 'ontime-types';
import { getCueCandidate, getIncrement, sanitiseCue } from './cueUtils.js';
describe('getIncrement()', () => {
it('increments number', () => {
expect(getIncrement('1')).toBe('2');
expect(getIncrement('10')).toBe('11');
expect(getIncrement('99')).toBe('100');
expect(getIncrement('101')).toBe('102');
});
it('increments decimal number', () => {
expect(getIncrement('1.1')).toBe('1.2');
expect(getIncrement('10.10')).toBe('10.11');
expect(getIncrement('99.99')).toBe('99.100');
expect(getIncrement('101.101')).toBe('101.102');
// NOTE: we know the below would fail, handling this amount of decimals is outside of scope
// expect(getIncrement('101.999')).toBe('101.1000');
});
// NOTE: we also know the following fails since we only handle one decimal
//it('handles multiple decimals', () => {
// expect(getIncrement('2.1.1')).toBe('2.1.2');
//});
it('finds last digit in string', () => {
expect(getIncrement('Presenter1')).toBe('Presenter2');
expect(getIncrement('Presenter10')).toBe('Presenter11');
expect(getIncrement('Presenter99')).toBe('Presenter100');
expect(getIncrement('Presenter101')).toBe('Presenter102');
});
it('adds a 2 if none is found', () => {
expect(getIncrement('Presenter')).toBe('Presenter2');
});
});
describe('findCueName()', () => {
describe('in the beginning of the rundown', () => {
it('names cue as 1 if next event does not collide', () => {
const testRundown = [
{ id: '1', cue: '10', type: SupportedEvent.Event },
{ id: '2', cue: '11', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown);
expect(cue).toBe('1');
});
it('creates decimal stem if next cue is 1', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '10', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown);
expect(cue).toBe('0.1');
});
});
describe('in the middle of the rundown', () => {
it('names cue as an increment if next event has different stem (case of numbers)', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '10', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('2');
});
it('names cue as an increment if next event has different stem (case of letters)', () => {
const testRundown = [
{ id: '1', cue: 'Presenter', type: SupportedEvent.Event },
{
id: '2',
cue: 'Interval',
type: SupportedEvent.Event,
},
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('Presenter2');
});
it('creates decimal stem if next cue has same stem (case of numbers)', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '2', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('1.1');
});
it('creates decimal stem if next cue has same stem (case of letters)', () => {
const testRundown = [
{ id: '1', cue: 'Presenter1', type: SupportedEvent.Event },
{ id: '2', cue: 'Presenter2', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('Presenter1.1');
});
});
});
describe('findCueName() with mixed events', () => {
describe('in the beginning of the rundown', () => {
it('names cue as 1 if next event does not collide', () => {
const testRundown = [
{ id: '1', cue: '10', type: SupportedEvent.Event },
{ id: '2', cue: '11', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown);
expect(cue).toBe('1');
});
it('creates decimal stem if next cue is 1', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '10', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown);
expect(cue).toBe('0.1');
});
});
describe('in the middle of the rundown', () => {
it('names cue as an increment if next event has different stem (case of numbers)', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '10', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('2');
});
it('names cue as an increment if next event has different stem (case of letters)', () => {
const testRundown = [
{ id: '1', cue: 'Presenter', type: SupportedEvent.Event },
{ id: '2', cue: 'Interval', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('Presenter2');
});
it('creates decimal stem if next cue has same stem (case of numbers)', () => {
const testRundown = [
{ id: '1', cue: '1', type: SupportedEvent.Event },
{ id: '2', cue: '2', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('1.1');
});
it('creates decimal stem if next cue has same stem (case of letters)', () => {
const testRundown = [
{ id: '1', cue: 'Presenter1', type: SupportedEvent.Event },
{ id: '2', cue: 'Presenter2', type: SupportedEvent.Event },
] as OntimeRundown;
const cue = getCueCandidate(testRundown, '1');
expect(cue).toBe('Presenter1.1');
});
});
});
describe('sanitiseCue()', () => {
it('removes spaces', () => {
expect(sanitiseCue(' test')).toBe('test');
expect(sanitiseCue(' test ')).toBe('test');
expect(sanitiseCue('test')).toBe('test');
expect(sanitiseCue('t e s t ')).toBe('test');
});
it('enforces . as decimals', () => {
expect(sanitiseCue('1,2')).toBe('1.2');
expect(sanitiseCue('1,2,3')).toBe('1.2.3');
});
});
+80
View File
@@ -0,0 +1,80 @@
import { OntimeEvent, OntimeRundown } from 'ontime-types';
import { getFirstEvent, getNextEvent } from '../rundown-utils/rundownUtils.js';
import { isNumeric } from '../types/types.js';
/**
* Finds if last characters in input are a number and increments
* @param input {string}
*/
export function getIncrement(input: string): string {
// Check if the input string contains a number at the end
const match = input.match(/^(\D*)(\d+)(\.\d+)?$/);
if (match) {
// If a number is found, extract the non-numeric prefix, integer part, and decimal part
let [, prefix, integerPart, decimalPart] = match;
if (decimalPart) {
if (decimalPart === '.99') {
decimalPart = '.100';
} else {
const addDecimal = '0'.repeat(decimalPart.length - 2) + '1';
const incrementedDecimal = (Number(decimalPart) + Number('0.' + addDecimal)).toFixed(decimalPart.length - 1);
decimalPart = incrementedDecimal.toString().replace('0.', '.');
}
return `${prefix}${integerPart}${decimalPart}`;
}
const incrementedInteger = Number(integerPart) + 1;
integerPart = incrementedInteger.toString();
return `${prefix}${integerPart}`;
}
// If no number is found, append "2" to the string and return the updated string
return input + '2';
}
/**
* Gets suitable name for a new event cue
* @param rundown {OntimeRundown}
* @param insertAfterId {string}
*/
export function getCueCandidate(rundown: OntimeRundown, insertAfterId?: string): string {
function addAtTop() {
const firstEventCue = getFirstEvent(rundown)?.cue;
if (isNumeric(firstEventCue)) {
return (Number(firstEventCue) / 10).toString();
}
return '1';
}
// we did not provide a element to go after, we attempt to go first so only need to check for a cue with value 1
if (typeof insertAfterId === 'undefined' || rundown.length === 0) {
return addAtTop();
}
const afterIndex = rundown.findIndex((event) => event.id === insertAfterId);
// we did not find the previous element, insert at top
if (afterIndex === -1) {
return addAtTop();
}
// get elements around
const previousEvent = rundown.at(afterIndex);
const nextEvent = getNextEvent(rundown, insertAfterId);
// try and increment the cue
let cue = getIncrement((previousEvent as OntimeEvent).cue);
// if increment is clashing with next, we add a decimal instead
if (cue === nextEvent?.cue) {
cue = (previousEvent as OntimeEvent).cue + '.1';
}
return cue;
}
export function sanitiseCue(cue: string) {
return cue.replaceAll(' ', '').replaceAll(',', '.');
}
@@ -1,6 +1,77 @@
import { OntimeRundown, SupportedEvent } from 'ontime-types';
import { dayInMs } from '../timeConstants.js';
import { getNextEvent, getPreviousEvent } from './rundownUtils';
import { calculateDuration } from './rundownUtils.js';
describe('getNextEvent()', () => {
it('returns the next event of type event', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Event },
];
const next = getNextEvent(testRundown as OntimeRundown, '1');
expect(next?.id).toBe('2');
});
it('ignores other event types', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Delay },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
];
const next = getNextEvent(testRundown as OntimeRundown, '1');
expect(next?.id).toBe('4');
});
it('returns null if none found', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Delay },
{ id: '3', type: SupportedEvent.Block },
];
const next = getNextEvent(testRundown as OntimeRundown, '1');
expect(next).toBe(null);
});
});
describe('getPreviousEvent()', () => {
it('returns the previous event of type event', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Event },
{ id: '3', type: SupportedEvent.Event },
];
const previous = getPreviousEvent(testRundown as OntimeRundown, '3');
expect(previous?.id).toBe('2');
});
it('ignores other event types', () => {
const testRundown = [
{ id: '1', type: SupportedEvent.Event },
{ id: '2', type: SupportedEvent.Delay },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
];
const previous = getPreviousEvent(testRundown as OntimeRundown, '4');
expect(previous?.id).toBe('1');
});
it('returns null if none found', () => {
const testRundown = [
{ id: '2', type: SupportedEvent.Delay },
{ id: '3', type: SupportedEvent.Block },
{ id: '4', type: SupportedEvent.Event },
];
const previous = getNextEvent(testRundown as OntimeRundown, '1');
expect(previous).toBe(null);
});
});
describe('calculateDuration()', () => {
describe('Given start and end values', () => {
it('is the difference between end and start', () => {
@@ -12,16 +83,8 @@ describe('calculateDuration()', () => {
describe('Handles edge cases', () => {
it('handles events that go over midnight', () => {
const duration = calculateDuration(51, 50);
expect(duration).not.toBe(-50);
expect(duration).toBe(dayInMs - 1);
});
it('when both are equal', () => {
const testStart = 1;
const testEnd = 1;
const val = calculateDuration(testStart, testEnd);
expect(val).toBe(testEnd - testStart);
});
it('handles no difference', () => {
const duration1 = calculateDuration(0, 0);
const duration2 = calculateDuration(dayInMs, dayInMs);
@@ -1,7 +1,100 @@
import { OntimeEvent, OntimeRundown } from 'ontime-types';
import { OntimeEvent, OntimeRundown, OntimeRundownEntry, SupportedEvent } from 'ontime-types';
import { dayInMs } from '../timeConstants.js';
/**
* Gets first event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeRundownEntry | null}
*/
export function getFirst(rundown: OntimeRundownEntry[]) {
return rundown.length ? rundown[0] : null;
}
/**
* Gets first scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @return {OntimeEvent | null}
*/
export function getFirstEvent(rundown: OntimeRundownEntry[]) {
for (let i = 0; i < rundown.length; i++) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
}
}
return null;
}
/**
* Gets next event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeRundownEntry | null}
*/
export function getNext(rundown: OntimeRundownEntry[], currentId: string): OntimeRundownEntry | null {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index + 1 < rundown.length) {
return rundown[index + 1];
} else {
return null;
}
}
/**
* Gets next scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getNextEvent(rundown: OntimeRundownEntry[], currentId: string): OntimeEvent | null {
const index = rundown.findIndex((event) => event.id === currentId);
if (index < 0) {
return null;
}
for (let i = index + 1; i < rundown.length; i++) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
}
}
return null;
}
/**
* Gets previous event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeRundownEntry | null}
*/
export function getPrevious(rundown: OntimeRundownEntry[], currentId: string) {
const index = rundown.findIndex((event) => event.id === currentId);
if (index !== -1 && index - 1 >= 0) {
return rundown[index - 1];
} else {
return null;
}
}
/**
* Gets previous scheduled event in rundown, if it exists
* @param {OntimeRundownEntry[]} rundown
* @param {string} currentId
* @return {OntimeEvent | null}
*/
export function getPreviousEvent(rundown: OntimeRundownEntry[], currentId: string): OntimeEvent | null {
const index = rundown.findIndex((event) => event.id === currentId);
if (index < 0) {
return null;
}
for (let i = index - 1; i >= 0; i--) {
if (rundown[i].type === SupportedEvent.Event) {
return rundown[i] as OntimeEvent;
}
}
return null;
}
/**
* @description calculates event duration considering midnight
* @param {number} timeStart
+18
View File
@@ -0,0 +1,18 @@
import { isNumeric } from './types.js';
describe('isNumeric()', () => {
it('identifies numeric values', () => {
const testCases = [12, 12.3, Infinity, -Infinity, 0, -0];
for (const tc of testCases) {
expect(isNumeric(tc)).toBe(true);
}
});
it('identifies string version of numeric values', () => {
const testCases = ['12', '12.3', 'Infinity', '-Infinity', '0', '-0'];
for (const tc of testCases) {
expect(isNumeric(tc)).toBe(true);
}
});
});
+11
View File
@@ -0,0 +1,11 @@
export function isNumeric(num: any) {
if (typeof num === 'number' && !isNaN(num)) {
return true;
}
if (typeof num === 'string' && num.trim() !== '') {
return !isNaN(parseFloat(num));
}
return false;
}