mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 02:13:48 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b59dc796cf | |||
| d70d7f2fd8 | |||
| 469d83dc0b | |||
| 59bff62f76 | |||
| 38e3ab979d | |||
| 2f29060fa4 | |||
| 2f7909ccc3 | |||
| 5f040092cb | |||
| 347c748dd9 | |||
| f234a1f892 | |||
| 0c5e87b7e7 | |||
| 64082ceac0 | |||
| f598e8dab1 | |||
| 0c84a3ff9e | |||
| c8760b5e9c | |||
| 34360a5b8f | |||
| 5e6debcce0 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ProjectRundownsList,
|
||||
RenumberCues,
|
||||
Rundown,
|
||||
RundownImportPayload,
|
||||
TransientEventPayload,
|
||||
} from 'ontime-types';
|
||||
|
||||
@@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise<AxiosResponse
|
||||
return axios.delete(`${rundownPath}/${rundownId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to apply an imported rundown using a merge strategy or into a new rundown
|
||||
*/
|
||||
export async function importRundownWithOptions(
|
||||
payload: RundownImportPayload,
|
||||
): Promise<AxiosResponse<ProjectRundownsList>> {
|
||||
return axios.post(`${rundownPath}/import`, payload);
|
||||
}
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
|
||||
@@ -95,6 +95,22 @@ export const useAuxTimersTime = createSelector((state: RuntimeStore) => {
|
||||
};
|
||||
});
|
||||
|
||||
export const useAuxTimersName = createSelector((state: RuntimeStore) => {
|
||||
return {
|
||||
aux1: state.auxtimer1.name,
|
||||
aux2: state.auxtimer2.name,
|
||||
aux3: state.auxtimer3.name,
|
||||
};
|
||||
});
|
||||
|
||||
export const useAuxTimersActive = createSelector((state: RuntimeStore) => {
|
||||
return (
|
||||
state.auxtimer1.playback === SimplePlayback.Start ||
|
||||
state.auxtimer2.playback === SimplePlayback.Start ||
|
||||
state.auxtimer3.playback === SimplePlayback.Start
|
||||
);
|
||||
});
|
||||
|
||||
export const useAuxTimerTime = (index: number) =>
|
||||
createSelector((state: RuntimeStore) => {
|
||||
if (index === 1) return state.auxtimer1.current;
|
||||
@@ -108,15 +124,18 @@ export const useAuxTimerControl = (index: number) =>
|
||||
return {
|
||||
playback: state.auxtimer1.playback,
|
||||
direction: state.auxtimer1.direction,
|
||||
name: state.auxtimer1.name,
|
||||
};
|
||||
if (index === 2)
|
||||
return {
|
||||
playback: state.auxtimer2.playback,
|
||||
direction: state.auxtimer2.direction,
|
||||
name: state.auxtimer2.name,
|
||||
};
|
||||
return {
|
||||
playback: state.auxtimer3.playback,
|
||||
direction: state.auxtimer3.direction,
|
||||
name: state.auxtimer3.name,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -139,6 +158,17 @@ export const setEventPlayback = {
|
||||
pause: () => sendSocket('pause', undefined),
|
||||
};
|
||||
|
||||
export const useTimerProgress = createSelector((state: RuntimeStore) => ({
|
||||
playback: state.timer.playback,
|
||||
phase: state.timer.phase,
|
||||
addedTime: state.timer.addedTime,
|
||||
secondaryTimer: state.timer.secondaryTimer,
|
||||
current: state.timer.current,
|
||||
expectedFinish: state.timer.expectedFinish,
|
||||
startedAt: state.timer.startedAt,
|
||||
isCountToEnd: state.eventNow?.countToEnd ?? false,
|
||||
}));
|
||||
|
||||
export const useTimer = createSelector((state: RuntimeStore) => ({
|
||||
...state.timer,
|
||||
}));
|
||||
|
||||
@@ -6,4 +6,5 @@ export const ontimePlaceholderSettings: Settings = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ type EditorSettingsStore = {
|
||||
defaultTimerType: TimerType;
|
||||
defaultEndAction: EndAction;
|
||||
inheritGroupColour: boolean;
|
||||
auxTimersCollapsed: boolean;
|
||||
setDefaultDuration: (defaultDuration: string) => void;
|
||||
setLinkPrevious: (linkPrevious: boolean) => void;
|
||||
setInheritGroupColour: (inheritGroupColour: boolean) => void;
|
||||
@@ -21,6 +22,7 @@ type EditorSettingsStore = {
|
||||
setDangerTime: (dangerTime: string) => void;
|
||||
setDefaultTimerType: (defaultTimerType: TimerType) => void;
|
||||
setDefaultEndAction: (defaultEndAction: EndAction) => void;
|
||||
setAuxTimersCollapsed: (auxTimersCollapsed: boolean) => void;
|
||||
};
|
||||
|
||||
export const editorSettingsDefaults = {
|
||||
@@ -32,6 +34,7 @@ export const editorSettingsDefaults = {
|
||||
timerType: TimerType.CountDown,
|
||||
endAction: EndAction.None,
|
||||
inheritGroupColour: false,
|
||||
auxTimersCollapsed: false,
|
||||
};
|
||||
|
||||
enum EditorSettingsKeys {
|
||||
@@ -43,6 +46,7 @@ enum EditorSettingsKeys {
|
||||
DefaultTimerType = 'ontime-default-timer-type',
|
||||
DefaultEndAction = 'ontime-default-end-action',
|
||||
InheritGroupColour = 'ontime-inherit-group-colour',
|
||||
AuxTimersCollapsed = 'ontime-aux-timers-collapsed',
|
||||
}
|
||||
|
||||
export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
@@ -67,6 +71,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
EditorSettingsKeys.InheritGroupColour,
|
||||
editorSettingsDefaults.inheritGroupColour,
|
||||
),
|
||||
auxTimersCollapsed: booleanFromLocalStorage(
|
||||
EditorSettingsKeys.AuxTimersCollapsed,
|
||||
editorSettingsDefaults.auxTimersCollapsed,
|
||||
),
|
||||
|
||||
setDefaultDuration: (defaultDuration) =>
|
||||
set(() => {
|
||||
@@ -110,5 +118,10 @@ export const useEditorSettings = create<EditorSettingsStore>((set) => {
|
||||
localStorage.setItem(EditorSettingsKeys.InheritGroupColour, String(inheritGroupColour));
|
||||
return { inheritGroupColour };
|
||||
}),
|
||||
setAuxTimersCollapsed: (auxTimersCollapsed) =>
|
||||
set(() => {
|
||||
localStorage.setItem(EditorSettingsKeys.AuxTimersCollapsed, String(auxTimersCollapsed));
|
||||
return { auxTimersCollapsed };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { removeFileExtension } from '../uploadUtils';
|
||||
|
||||
describe('removeFileExtension()', () => {
|
||||
it('removes a trailing extension', () => {
|
||||
expect(removeFileExtension('show.xlsx')).toBe('show');
|
||||
});
|
||||
|
||||
it('only removes the last extension', () => {
|
||||
expect(removeFileExtension('my.show.xlsx')).toBe('my.show');
|
||||
});
|
||||
|
||||
it('returns the name unchanged when there is no extension', () => {
|
||||
expect(removeFileExtension('rundown')).toBe('rundown');
|
||||
});
|
||||
|
||||
it('does not treat a leading dot as an extension', () => {
|
||||
expect(removeFileExtension('.gitignore')).toBe('.gitignore');
|
||||
});
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(removeFileExtension('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function getAuxTimerLabel(name: string | undefined, fallback: string): string {
|
||||
const custom = name?.trim();
|
||||
return custom ? custom : fallback;
|
||||
}
|
||||
|
||||
/** Combines the aux timer's index with its custom name, eg. "Aux 1: Speaker" */
|
||||
export function getAuxTimerIndexedLabel(name: string | undefined, index: number): string {
|
||||
const custom = name?.trim();
|
||||
return custom ? `Aux ${index}: ${custom}` : `Aux ${index}`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
MILLIS_PER_HOUR,
|
||||
MILLIS_PER_MINUTE,
|
||||
MILLIS_PER_SECOND,
|
||||
dayInMs,
|
||||
formatFromMillis,
|
||||
getExpectedStart,
|
||||
} from 'ontime-utils';
|
||||
@@ -29,6 +30,11 @@ export function nowInMillis(): number {
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
export function normaliseWallClock(time: number): number {
|
||||
const timeOfDay = time % dayInMs;
|
||||
return timeOfDay < 0 ? timeOfDay + dayInMs : timeOfDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Resolves format from url and store
|
||||
* @return {string|null} A format string like "hh:mm:ss a" or null
|
||||
|
||||
@@ -38,6 +38,18 @@ export function validateProjectFile(file: File) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a trailing file extension from a file name (e.g. "show.xlsx" -> "show")
|
||||
* A leading dot (dotfiles like ".gitignore") is not treated as an extension
|
||||
*/
|
||||
export function removeFileExtension(fileName: string): string {
|
||||
const lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot <= 0) {
|
||||
return fileName;
|
||||
}
|
||||
return fileName.slice(0, lastDot);
|
||||
}
|
||||
|
||||
export function isExcelFile(file: File | null) {
|
||||
return file?.name.endsWith('.xlsx');
|
||||
}
|
||||
|
||||
+36
-13
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
ImportedFields,
|
||||
RundownImportMode,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
@@ -22,7 +24,7 @@ import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import useRundown from '../../../../../common/hooks-query/useRundown';
|
||||
import { validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||
import { removeFileExtension, validateExcelImport } from '../../../../../common/utils/uploadUtils';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import GSheetSetup from './GSheetSetup';
|
||||
import SheetImportEditor from './sheet-import/SheetImportEditor';
|
||||
@@ -35,6 +37,7 @@ const googleSheetDocsUrl = 'https://docs.getontime.no/features/import-spreadshee
|
||||
type ActiveSource =
|
||||
| {
|
||||
kind: 'excel';
|
||||
fileName: string;
|
||||
worksheetNames: string[];
|
||||
initialWorksheetMetadata: SpreadsheetWorksheetMetadata | null;
|
||||
closedByUser: boolean;
|
||||
@@ -55,7 +58,7 @@ export default function SourcesPanel() {
|
||||
const [activeSource, setActiveSource] = useState<ActiveSource | null>(null);
|
||||
|
||||
const { data: currentRundown } = useRundown();
|
||||
const { importRundown } = useSpreadsheetImport();
|
||||
const { applyImport } = useSpreadsheetImport();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -74,6 +77,7 @@ export default function SourcesPanel() {
|
||||
const worksheetOptions = await uploadExcel(fileToUpload);
|
||||
setActiveSource({
|
||||
kind: 'excel',
|
||||
fileName: fileToUpload.name,
|
||||
worksheetNames: worksheetOptions.worksheets,
|
||||
initialWorksheetMetadata: worksheetOptions.metadata,
|
||||
closedByUser: false,
|
||||
@@ -126,21 +130,32 @@ export default function SourcesPanel() {
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleApplyImport = async (preview: SpreadsheetPreviewResponse) => {
|
||||
const handleApplyImport = async (
|
||||
preview: SpreadsheetPreviewResponse,
|
||||
mode: RundownImportMode,
|
||||
newRundownTitle: string,
|
||||
providedFields: ImportedFields,
|
||||
) => {
|
||||
if (mode === 'new') {
|
||||
const title = newRundownTitle.trim() || preview.rundown.title;
|
||||
await applyImport({ mode: 'new', rundown: { ...preview.rundown, title }, customFields: preview.customFields });
|
||||
handleFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentRundown) {
|
||||
throw new Error('No current rundown loaded');
|
||||
}
|
||||
|
||||
await importRundown(
|
||||
{
|
||||
[currentRundown.id]: {
|
||||
...preview.rundown,
|
||||
id: currentRundown.id,
|
||||
title: currentRundown.title,
|
||||
},
|
||||
},
|
||||
preview.customFields,
|
||||
);
|
||||
// override or merge into the current rundown; merge uses providedFields to know which columns
|
||||
// the sheet supplied so matched events keep the rest (e.g. automations)
|
||||
await applyImport({
|
||||
mode,
|
||||
targetRundownId: currentRundown.id,
|
||||
rundown: preview.rundown,
|
||||
customFields: preview.customFields,
|
||||
providedFields,
|
||||
});
|
||||
handleFinished();
|
||||
};
|
||||
|
||||
@@ -208,6 +223,13 @@ export default function SourcesPanel() {
|
||||
if (activeSource.kind === 'excel') return 'excel';
|
||||
return `gsheet:${activeSource.sheetId}`;
|
||||
})();
|
||||
// suggested name when importing into a new rundown: the spreadsheet file name (without extension)
|
||||
// for Excel, or the document title for Google Sheets
|
||||
const spreadsheetName = (() => {
|
||||
if (!activeSource) return '';
|
||||
if (activeSource.kind === 'excel') return removeFileExtension(activeSource.fileName);
|
||||
return activeSource.title;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
@@ -287,6 +309,7 @@ export default function SourcesPanel() {
|
||||
bodyElements={
|
||||
<SheetImportEditor
|
||||
sourceKey={sourceKey ?? 'spreadsheet'}
|
||||
defaultRundownName={spreadsheetName}
|
||||
worksheetNames={activeSource?.worksheetNames ?? []}
|
||||
initialMetadata={activeSource?.initialWorksheetMetadata ?? null}
|
||||
loadMetadata={loadWorksheetMetadata}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import type { RundownImportMode, SpreadsheetPreviewResponse } from 'ontime-types';
|
||||
import { isOntimeEvent, isPlayableEvent, Playback } from 'ontime-types';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import { usePlayback, useSelectedEventId } from '../../../../../../common/hooks/useSocket';
|
||||
|
||||
interface ApplyImportButtonProps {
|
||||
preview: SpreadsheetPreviewResponse | null;
|
||||
mode: RundownImportMode;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
onApply: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply action for the spreadsheet import.
|
||||
* Subscribes to playback state on its own so playback updates do not re-render the whole editor.
|
||||
* Requires a second click to confirm when applying would stop a running playback.
|
||||
*/
|
||||
export default function ApplyImportButton({ preview, mode, disabled, loading, onApply }: ApplyImportButtonProps) {
|
||||
const playback = usePlayback();
|
||||
const loadedEventId = useSelectedEventId();
|
||||
|
||||
// the loaded (playing) event loses its playback unless it still exists as a playable event after the import
|
||||
const loadedEntry = loadedEventId ? preview?.rundown.entries[loadedEventId] : undefined;
|
||||
const willLoadedEventBeOverriden = !(
|
||||
loadedEntry !== undefined &&
|
||||
isOntimeEvent(loadedEntry) &&
|
||||
isPlayableEvent(loadedEntry)
|
||||
);
|
||||
|
||||
// applying stops playback when creating a new rundown, or when the playing event does not survive
|
||||
const willStopPlayback = playback !== Playback.Stop && (mode === 'new' || willLoadedEventBeOverriden);
|
||||
|
||||
// two-step confirmation before applying an import that stops playback
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
useEffect(() => {
|
||||
setConfirmStop(false);
|
||||
}, [mode, preview]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (willStopPlayback && !confirmStop) {
|
||||
setConfirmStop(true);
|
||||
return;
|
||||
}
|
||||
onApply();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant='primary' onClick={handleClick} disabled={disabled} loading={loading}>
|
||||
{willStopPlayback && confirmStop ? 'Confirm — stop playback & apply import' : 'Apply import'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+5
@@ -33,6 +33,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.importModeTrigger {
|
||||
min-width: 12rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.addColumnTrigger {
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
|
||||
+116
-29
@@ -1,10 +1,20 @@
|
||||
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||
import type {
|
||||
ImportedFields,
|
||||
RundownImportMode,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
} from 'ontime-types';
|
||||
import type { ImportMap } from 'ontime-utils';
|
||||
import { IoArrowUpOutline, IoEye } from 'react-icons/io5';
|
||||
import { useMemo } from 'react';
|
||||
import { IoArrowUpOutline, IoCheckmark, IoChevronDown, IoEye, IoWarningOutline } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../../../common/components/buttons/Button';
|
||||
import { DropdownMenu, DropdownMenuOption } from '../../../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Input from '../../../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../../../common/components/select/Select';
|
||||
import * as Panel from '../../../../panel-utils/PanelUtils';
|
||||
import ApplyImportButton from './ApplyImportButton';
|
||||
import { isIdColumnMapped } from './importMapUtils';
|
||||
import PreviewTable from './preview/PreviewTable';
|
||||
import SheetImportMappingPane from './SheetImportMappingPane';
|
||||
import { useSheetImportForm } from './useSheetImportForm';
|
||||
@@ -13,17 +23,46 @@ import style from './SheetImportEditor.module.scss';
|
||||
|
||||
interface SheetImportEditorProps {
|
||||
sourceKey: string;
|
||||
defaultRundownName: string;
|
||||
worksheetNames: string[];
|
||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||
previewImport: (importMap: ImportMap) => Promise<SpreadsheetPreviewResponse>;
|
||||
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
||||
onApply: (
|
||||
preview: SpreadsheetPreviewResponse,
|
||||
mode: RundownImportMode,
|
||||
newRundownTitle: string,
|
||||
providedFields: ImportedFields,
|
||||
) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
onExport?: (importMap: ImportMap) => Promise<void>;
|
||||
}
|
||||
|
||||
const importModeOptions: Array<{
|
||||
value: RundownImportMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: 'override',
|
||||
label: 'Replace current rundown',
|
||||
description: 'Spreadsheet data completely replaces current rundown',
|
||||
},
|
||||
{
|
||||
value: 'merge',
|
||||
label: 'Merge with current rundown',
|
||||
description: 'Merge entries referencing their IDs, entries not present in spreadsheet rundown are deleted',
|
||||
},
|
||||
{
|
||||
value: 'new',
|
||||
label: 'New rundown',
|
||||
description: 'Create a new rundown to import the data into. Loads the new rundown',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SheetImportEditor({
|
||||
sourceKey,
|
||||
defaultRundownName,
|
||||
worksheetNames,
|
||||
initialMetadata,
|
||||
loadMetadata,
|
||||
@@ -49,11 +88,16 @@ export default function SheetImportEditor({
|
||||
isBusy,
|
||||
canPreview,
|
||||
displayError,
|
||||
importMode,
|
||||
setImportMode,
|
||||
newRundownTitle,
|
||||
setNewRundownTitle,
|
||||
handlePreviewSubmit,
|
||||
handleExportSubmit,
|
||||
handleApply,
|
||||
} = useSheetImportForm({
|
||||
sourceKey,
|
||||
defaultRundownName,
|
||||
worksheetNames,
|
||||
initialMetadata,
|
||||
loadMetadata,
|
||||
@@ -62,6 +106,19 @@ export default function SheetImportEditor({
|
||||
onExport,
|
||||
});
|
||||
|
||||
const selectedImportMode = importModeOptions.find((option) => option.value === importMode) ?? importModeOptions[0];
|
||||
const importModeItems = useMemo<DropdownMenuOption[]>(
|
||||
() =>
|
||||
importModeOptions.map((option) => ({
|
||||
type: 'item',
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
icon: importMode === option.value ? IoCheckmark : undefined,
|
||||
onClick: () => setImportMode(option.value),
|
||||
})),
|
||||
[importMode, setImportMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel.Section as='form' id='spreadsheet-import-workspace' className={style.editor} onSubmit={handlePreviewSubmit}>
|
||||
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorToolbar}>
|
||||
@@ -107,33 +164,63 @@ export default function SheetImportEditor({
|
||||
</div>
|
||||
|
||||
{displayError && <Panel.Error>{displayError}</Panel.Error>}
|
||||
<Panel.InlineElements align='end' wrap='wrap' className={style.editorFooter}>
|
||||
<Button onClick={onCancel} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
{onExport && (
|
||||
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
||||
<IoArrowUpOutline />
|
||||
Export
|
||||
{importMode === 'merge' && !isIdColumnMapped(values) && (
|
||||
<Panel.Description tone='warning'>
|
||||
<IoWarningOutline /> No ID column mapped — merge matches entries by ID, so it will behave like Replace. Export
|
||||
your rundown to a spreadsheet first to keep its IDs.
|
||||
</Panel.Description>
|
||||
)}
|
||||
<Panel.InlineElements align='apart' wrap='wrap' className={style.editorFooter}>
|
||||
<Panel.InlineElements wrap='wrap'>
|
||||
<label className={style.worksheetControl}>
|
||||
<span className={style.worksheetLabel}>Import mode</span>
|
||||
<DropdownMenu
|
||||
render={<Button className={style.importModeTrigger} variant='subtle-white' />}
|
||||
items={importModeItems}
|
||||
>
|
||||
{selectedImportMode.label}
|
||||
<IoChevronDown />
|
||||
</DropdownMenu>
|
||||
</label>
|
||||
{importMode === 'new' && (
|
||||
<label className={style.worksheetControl}>
|
||||
<span className={style.worksheetLabel}>New rundown name</span>
|
||||
<Input
|
||||
value={newRundownTitle}
|
||||
onChange={(event) => setNewRundownTitle(event.target.value)}
|
||||
placeholder={state.preview?.rundown.title || 'Imported rundown'}
|
||||
aria-label='New rundown name'
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</Panel.InlineElements>
|
||||
<Panel.InlineElements wrap='wrap'>
|
||||
<Button onClick={onCancel} disabled={isBusy}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={state.preview ? undefined : 'primary'}
|
||||
onClick={handlePreviewSubmit}
|
||||
disabled={!canPreview}
|
||||
loading={state.loading === 'preview'}
|
||||
>
|
||||
<IoEye />
|
||||
Preview import
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleApply}
|
||||
disabled={!state.preview || isBusy}
|
||||
loading={state.loading === 'apply'}
|
||||
>
|
||||
Apply import
|
||||
</Button>
|
||||
{onExport && (
|
||||
<Button onClick={handleExportSubmit} disabled={!canPreview} loading={state.loading === 'export'}>
|
||||
<IoArrowUpOutline />
|
||||
Export
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant={state.preview ? undefined : 'primary'}
|
||||
onClick={handlePreviewSubmit}
|
||||
disabled={!canPreview}
|
||||
loading={state.loading === 'preview'}
|
||||
>
|
||||
<IoEye />
|
||||
Preview import
|
||||
</Button>
|
||||
<ApplyImportButton
|
||||
preview={state.preview}
|
||||
mode={importMode}
|
||||
disabled={!state.preview || isBusy}
|
||||
loading={state.loading === 'apply'}
|
||||
onApply={handleApply}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
+68
-1
@@ -1,15 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
builtInFieldDefs,
|
||||
convertToImportMap,
|
||||
createDefaultFormValues,
|
||||
defaultImportMode,
|
||||
getImportWarnings,
|
||||
getPersistedImportMode,
|
||||
getProvidedImportFields,
|
||||
getResolvedCustomFields,
|
||||
isIdColumnMapped,
|
||||
persistImportMode,
|
||||
} from '../importMapUtils';
|
||||
|
||||
const cueIndex = builtInFieldDefs.findIndex((field) => field.label === 'Cue');
|
||||
const titleIndex = builtInFieldDefs.findIndex((field) => field.label === 'Title');
|
||||
const idIndex = builtInFieldDefs.findIndex((field) => field.importKey === 'id');
|
||||
|
||||
describe('getImportWarnings()', () => {
|
||||
it('warns when two mappings target the same spreadsheet column', () => {
|
||||
@@ -128,3 +134,64 @@ describe('convertToImportMap()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProvidedImportFields()', () => {
|
||||
it('reports the mapped built-in and custom fields the sheet supplies', () => {
|
||||
const values = createDefaultFormValues();
|
||||
values.builtIn[titleIndex] = { header: 'title', enabled: true };
|
||||
values.builtIn[cueIndex] = { header: '', enabled: false };
|
||||
values.custom = [{ ontimeName: 'ignored', importName: 'FOH/Monitor' }];
|
||||
|
||||
const provided = getProvidedImportFields(convertToImportMap(values));
|
||||
|
||||
// enabled built-in mappings are reported as event fields, disabled ones are not
|
||||
expect(provided.event).toContain('title');
|
||||
expect(provided.event).not.toContain('cue');
|
||||
// the id column is only used for matching, never overwritten
|
||||
expect(provided.event).not.toContain('id');
|
||||
// custom fields are reported symmetrically by their resolved Ontime name
|
||||
expect(provided.custom).toStrictEqual(['FOH Monitor']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIdColumnMapped()', () => {
|
||||
it('is true when the ID field is enabled with a header', () => {
|
||||
// the default form maps the ID column
|
||||
expect(isIdColumnMapped(createDefaultFormValues())).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the ID field is disabled', () => {
|
||||
const values = createDefaultFormValues();
|
||||
values.builtIn[idIndex] = { header: 'id', enabled: false };
|
||||
expect(isIdColumnMapped(values)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when the ID field header is blank', () => {
|
||||
const values = createDefaultFormValues();
|
||||
values.builtIn[idIndex] = { header: ' ', enabled: true };
|
||||
expect(isIdColumnMapped(values)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import mode persistence', () => {
|
||||
const sourceKey = 'excel-test';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to override when nothing is persisted', () => {
|
||||
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
||||
expect(defaultImportMode).toBe('override');
|
||||
});
|
||||
|
||||
it('round-trips a persisted value', () => {
|
||||
persistImportMode(sourceKey, 'merge');
|
||||
expect(getPersistedImportMode(sourceKey)).toBe('merge');
|
||||
});
|
||||
|
||||
it('falls back to the default when the persisted value is invalid', () => {
|
||||
persistImportMode(sourceKey, 'nonsense' as never);
|
||||
expect(getPersistedImportMode(sourceKey)).toBe('override');
|
||||
});
|
||||
});
|
||||
|
||||
+58
-3
@@ -1,3 +1,4 @@
|
||||
import type { ImportedFields, RundownImportMode } from 'ontime-types';
|
||||
import type { ImportMap } from 'ontime-utils';
|
||||
|
||||
import { makeStageKey } from '../../../../../../common/utils/localStorage';
|
||||
@@ -49,6 +50,16 @@ export function createDefaultFormValues(): ImportFormValues {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the mapping supplies an ID column. Merge matches entries by ID, so without one every
|
||||
* imported entry gets a fresh id and nothing can reconcile with the current rundown.
|
||||
*/
|
||||
export function isIdColumnMapped(values: ImportFormValues): boolean {
|
||||
const idIndex = builtInFieldDefs.findIndex((def) => def.importKey === 'id');
|
||||
const field = values.builtIn[idIndex];
|
||||
return Boolean(field?.enabled && field.header.trim());
|
||||
}
|
||||
|
||||
function sanitiseOntimeCustomFieldLabel(importName: string): string {
|
||||
// Replace punctuation with spaces, then collapse repeated whitespace into single spaces.
|
||||
const sanitised = importName
|
||||
@@ -81,6 +92,24 @@ export function getResolvedCustomFields(customFields: ImportFormValues['custom']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fields the import map supplies — the complete description of what the incoming data
|
||||
* provides, for both built-in and custom fields. A merge uses this to patch exactly these fields
|
||||
* onto a matched event and keep everything else (e.g. automations) untouched.
|
||||
* Import-map keys are OntimeEvent field names; `worksheet`/`custom` are meta and `id` is only used
|
||||
* for matching, not overwritten.
|
||||
*/
|
||||
export function getProvidedImportFields(importMap: ImportMap): ImportedFields {
|
||||
const event: string[] = [];
|
||||
for (const [key, value] of Object.entries(importMap)) {
|
||||
if (key === 'worksheet' || key === 'custom' || key === 'id') continue;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
event.push(key);
|
||||
}
|
||||
}
|
||||
return { event, custom: Object.keys(importMap.custom) };
|
||||
}
|
||||
|
||||
export function convertToImportMap(values: ImportFormValues): ImportMap {
|
||||
const custom = getResolvedCustomFields(values.custom).reduce<Record<string, string>>(
|
||||
(accumulator, { ontimeName, importName }) => {
|
||||
@@ -130,12 +159,12 @@ function isPersistedFormValues(obj: unknown): obj is ImportFormValues {
|
||||
export function getPersistedImportState(sourceKey: string): { values: ImportFormValues; isPersisted: boolean } {
|
||||
const storageKey = getImportMapKey(sourceKey);
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
const persistedData = localStorage.getItem(storageKey);
|
||||
if (!persistedData) {
|
||||
return { values: createDefaultFormValues(), isPersisted: false };
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const parsed: unknown = JSON.parse(persistedData);
|
||||
if (isPersistedFormValues(parsed)) {
|
||||
return { values: parsed, isPersisted: true };
|
||||
}
|
||||
@@ -150,6 +179,32 @@ export function getPersistedImportState(sourceKey: string): { values: ImportForm
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The import mode (new / merge / override) is persisted separately from the field mapping
|
||||
* so the mapping schema guard stays untouched.
|
||||
*/
|
||||
|
||||
/** Default import mode: replace matched elements in the current rundown */
|
||||
export const defaultImportMode: RundownImportMode = 'override';
|
||||
|
||||
function getImportModeKey(sourceKey: string) {
|
||||
return makeStageKey(`import-mode:${sourceKey}`);
|
||||
}
|
||||
|
||||
/** Persists the import mode for a given source */
|
||||
export function persistImportMode(sourceKey: string, mode: RundownImportMode) {
|
||||
localStorage.setItem(getImportModeKey(sourceKey), mode);
|
||||
}
|
||||
|
||||
/** Reads the persisted import mode for a source, falling back to the default when absent or invalid */
|
||||
export function getPersistedImportMode(sourceKey: string): RundownImportMode {
|
||||
const persisted = localStorage.getItem(getImportModeKey(sourceKey));
|
||||
if (persisted === 'new' || persisted === 'merge' || persisted === 'override') {
|
||||
return persisted;
|
||||
}
|
||||
return defaultImportMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates import mappings and generates warnings for duplicate or missing spreadsheet columns.
|
||||
*/
|
||||
|
||||
+34
-5
@@ -1,7 +1,12 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SpreadsheetPreviewResponse, SpreadsheetWorksheetMetadata } from 'ontime-types';
|
||||
import type {
|
||||
ImportedFields,
|
||||
RundownImportMode,
|
||||
SpreadsheetPreviewResponse,
|
||||
SpreadsheetWorksheetMetadata,
|
||||
} from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../../common/api/utils';
|
||||
@@ -11,8 +16,11 @@ import {
|
||||
builtInFieldDefs,
|
||||
convertToImportMap,
|
||||
getImportWarnings,
|
||||
getPersistedImportMode,
|
||||
getPersistedImportState,
|
||||
getProvidedImportFields,
|
||||
getResolvedCustomFields,
|
||||
persistImportMode,
|
||||
persistImportState,
|
||||
} from './importMapUtils';
|
||||
import { deriveHeaderOptionsState } from './spreadsheetImportUtils';
|
||||
@@ -100,16 +108,23 @@ function buildColumnLabels(values: ImportFormValues): string[] {
|
||||
|
||||
interface UseSheetImportFormProps {
|
||||
sourceKey: string;
|
||||
defaultRundownName: string;
|
||||
worksheetNames: string[];
|
||||
initialMetadata: SpreadsheetWorksheetMetadata | null;
|
||||
loadMetadata: (worksheet: string) => Promise<SpreadsheetWorksheetMetadata>;
|
||||
previewImport: (importMap: ReturnType<typeof convertToImportMap>) => Promise<SpreadsheetPreviewResponse>;
|
||||
onApply: (preview: SpreadsheetPreviewResponse) => Promise<void>;
|
||||
onApply: (
|
||||
preview: SpreadsheetPreviewResponse,
|
||||
mode: RundownImportMode,
|
||||
newRundownTitle: string,
|
||||
providedFields: ImportedFields,
|
||||
) => Promise<void>;
|
||||
onExport?: (importMap: ReturnType<typeof convertToImportMap>) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSheetImportForm({
|
||||
sourceKey,
|
||||
defaultRundownName,
|
||||
worksheetNames,
|
||||
initialMetadata,
|
||||
loadMetadata,
|
||||
@@ -170,6 +185,8 @@ export function useSheetImportForm({
|
||||
const columnLabels = buildColumnLabels(values);
|
||||
|
||||
const [state, dispatch] = useReducer(importReducer, initialImportState);
|
||||
const [importMode, setImportMode] = useState<RundownImportMode>(() => getPersistedImportMode(sourceKey));
|
||||
const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName);
|
||||
const warnings = getImportWarnings(values, headers);
|
||||
const warningCount = Object.values(warnings).filter(Boolean).length;
|
||||
const previewRef = useRef<SpreadsheetPreviewResponse | null>(null);
|
||||
@@ -182,6 +199,12 @@ export function useSheetImportForm({
|
||||
dispatch({ type: 'reset' });
|
||||
}, [initialFormValues, reset]);
|
||||
|
||||
// Update import mode and rundown name if the source changes
|
||||
useEffect(() => {
|
||||
setImportMode(getPersistedImportMode(sourceKey));
|
||||
setNewRundownTitle(defaultRundownName);
|
||||
}, [sourceKey, defaultRundownName]);
|
||||
|
||||
// Keep the worksheet selection valid if the available worksheets change underneath the form.
|
||||
useEffect(() => {
|
||||
if (worksheetNames.length === 0) return;
|
||||
@@ -241,13 +264,15 @@ export function useSheetImportForm({
|
||||
|
||||
try {
|
||||
dispatch({ type: 'startApply' });
|
||||
await onApply(state.preview);
|
||||
const providedFields = getProvidedImportFields(convertToImportMap(getValues()));
|
||||
await onApply(state.preview, importMode, newRundownTitle, providedFields);
|
||||
persistImportState(sourceKey, getValues());
|
||||
persistImportMode(sourceKey, importMode);
|
||||
dispatch({ type: 'applySuccess' });
|
||||
} catch (error) {
|
||||
dispatch({ type: 'failure', error: maybeAxiosError(error) });
|
||||
}
|
||||
}, [getValues, onApply, sourceKey, state.preview]);
|
||||
}, [getValues, importMode, newRundownTitle, onApply, sourceKey, state.preview]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (formValues: ImportFormValues) => {
|
||||
@@ -300,6 +325,10 @@ export function useSheetImportForm({
|
||||
isBusy,
|
||||
canPreview,
|
||||
displayError,
|
||||
importMode,
|
||||
setImportMode,
|
||||
newRundownTitle,
|
||||
setNewRundownTitle,
|
||||
handlePreviewSubmit: handleSubmit(handlePreview),
|
||||
handleExportSubmit: handleSubmit(handleExport),
|
||||
handleApply,
|
||||
|
||||
+9
-22
@@ -1,30 +1,17 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CustomFields, ProjectRundowns } from 'ontime-types';
|
||||
import { RundownImportPayload } from 'ontime-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { CUSTOM_FIELDS, RUNDOWN } from '../../../../../common/api/constants';
|
||||
import { patchData } from '../../../../../common/api/db';
|
||||
import { importRundownWithOptions } from '../../../../../common/api/rundown';
|
||||
|
||||
export default function useSpreadsheetImport() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/** applies rundown and customFields to current project */
|
||||
const importRundown = useCallback(
|
||||
async (rundowns: ProjectRundowns, customFields: CustomFields) => {
|
||||
await patchData({ rundowns, customFields });
|
||||
// we are unable to optimistically set the rundown since we need
|
||||
// it to be normalised
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: RUNDOWN,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: CUSTOM_FIELDS,
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
/** applies a spreadsheet import: override or merge into the current rundown, or create a new one */
|
||||
const applyImport = useCallback(async (payload: RundownImportPayload) => {
|
||||
// the backend broadcasts a refetch once the rundown is parsed and applied, so the caches update
|
||||
// through that single path rather than racing it with an optimistic write from here
|
||||
await importRundownWithOptions(payload);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
importRundown,
|
||||
applyImport,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Settings } from 'ontime-types';
|
||||
import { auxTimerNameMaxLength } from 'ontime-utils';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { postSettings } from '../../../../common/api/settings';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import useSettings from '../../../../common/hooks-query/useSettings';
|
||||
import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
export default function AuxTimerSettings() {
|
||||
const { data, status, refetch } = useSettings();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
setError,
|
||||
formState: { isSubmitting, isDirty, errors },
|
||||
} = useForm<Settings>({
|
||||
defaultValues: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
reset(data);
|
||||
}
|
||||
}, [data, reset]);
|
||||
|
||||
const onSubmit = async (formData: Settings) => {
|
||||
try {
|
||||
await postSettings(formData);
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
setError('root', { message });
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
reset(data);
|
||||
};
|
||||
|
||||
const isLoading = status === 'pending';
|
||||
|
||||
return (
|
||||
<Panel.Section
|
||||
as='form'
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
onKeyDown={(event) => preventEscape(event, onReset)}
|
||||
id='aux-timer-settings'
|
||||
>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Aux timers
|
||||
<Panel.InlineElements>
|
||||
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button type='submit' loading={isSubmitting} disabled={!isDirty} variant='primary'>
|
||||
Save
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Info>Give the aux timers custom names. Names are shown across the editor controls and views.</Info>
|
||||
<Panel.Loader isLoading={isLoading} />
|
||||
<Panel.Error>{errors.root?.message}</Panel.Error>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 1' description='Custom name for aux timer 1' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 1' {...register('auxTimerNames.0')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 2' description='Custom name for aux timer 2' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 2' {...register('auxTimerNames.1')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Aux timer 3' description='Custom name for aux timer 3' />
|
||||
<Input maxLength={auxTimerNameMaxLength} placeholder='Aux 3' {...register('auxTimerNames.2')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import AuxTimerSettings from './AuxTimerSettings';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import McpSection from './McpSection';
|
||||
import ProjectData from './ProjectData';
|
||||
@@ -12,6 +13,7 @@ import ViewSettings from './ViewSettings';
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const auxTimersRef = useScrollIntoView<HTMLDivElement>('aux-timers', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const mcpRef = useScrollIntoView<HTMLDivElement>('mcp', location);
|
||||
@@ -26,6 +28,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={generalRef}>
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
<div ref={auxTimersRef}>
|
||||
<AuxTimerSettings />
|
||||
</div>
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ const staticOptions = [
|
||||
secondary: [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__aux-timers', label: 'Aux timers' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__mcp', label: 'MCP Server' },
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auxHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.auxHeaderButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.activeIndicator {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: $active-indicator;
|
||||
}
|
||||
|
||||
.auxTimers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
import { IoChevronDown, IoChevronUp, IoSettingsOutline } from 'react-icons/io5';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useAuxTimersActive, usePlaybackControl } from '../../../common/hooks/useSocket';
|
||||
import { useEditorSettings } from '../../../common/stores/editorSettings';
|
||||
import useAppSettingsNavigation from '../../app-settings/useAppSettingsNavigation';
|
||||
import AddTime from './add-time/AddTime';
|
||||
import { AuxTimer } from './aux-timer/AuxTimer';
|
||||
import PlaybackButtons from './playback-buttons/PlaybackButtons';
|
||||
@@ -8,6 +14,9 @@ import style from './PlaybackControl.module.scss';
|
||||
|
||||
export default function PlaybackControl() {
|
||||
const data = usePlaybackControl();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const { auxTimersCollapsed, setAuxTimersCollapsed } = useEditorSettings();
|
||||
const isAuxTimerActive = useAuxTimersActive();
|
||||
|
||||
return (
|
||||
<div className={style.mainContainer}>
|
||||
@@ -20,11 +29,42 @@ export default function PlaybackControl() {
|
||||
selectedEventIndex={data.selectedEventIndex}
|
||||
timerPhase={data.timerPhase}
|
||||
/>
|
||||
<div className={style.auxTimers}>
|
||||
<AuxTimer index={1} />
|
||||
<AuxTimer index={2} />
|
||||
<AuxTimer index={3} />
|
||||
<div className={style.auxHeader}>
|
||||
<span className={style.label}>
|
||||
Aux timers
|
||||
{auxTimersCollapsed && isAuxTimerActive && <span className={style.activeIndicator} />}
|
||||
</span>
|
||||
<div className={style.auxHeaderButtons}>
|
||||
<Tooltip
|
||||
text='Name aux timers'
|
||||
render={
|
||||
<IconButton
|
||||
size='small'
|
||||
variant='subtle-white'
|
||||
aria-label='Name aux timers'
|
||||
onClick={() => setLocation('settings__aux-timers')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<IoSettingsOutline />
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size='small'
|
||||
variant='subtle-white'
|
||||
aria-label={auxTimersCollapsed ? 'Expand aux timers' : 'Collapse aux timers'}
|
||||
onClick={() => setAuxTimersCollapsed(!auxTimersCollapsed)}
|
||||
>
|
||||
{auxTimersCollapsed ? <IoChevronUp /> : <IoChevronDown />}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{!auxTimersCollapsed && (
|
||||
<div className={style.auxTimers}>
|
||||
<AuxTimer index={1} />
|
||||
<AuxTimer index={2} />
|
||||
<AuxTimer index={3} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
.label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
// aux timers sit in a 3 column grid, without this a long name would grow its column
|
||||
// instead of shrinking to it, breaking the equal column layout
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.labelText {
|
||||
display: block;
|
||||
font-size: $inner-section-text-size;
|
||||
color: $label-gray;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
import { IoArrowDown, IoArrowUp, IoPause, IoPlay, IoStop } from 'react-icons/io5';
|
||||
|
||||
import TimeInput from '../../../../common/components/input/time-input/TimeInput';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import { setAuxTimer, useAuxTimerControl, useAuxTimerTime } from '../../../../common/hooks/useSocket';
|
||||
import { getAuxTimerIndexedLabel } from '../../../../common/utils/auxTimerUtils';
|
||||
import TapButton from '../tap-button/TapButton';
|
||||
|
||||
import style from './AuxTimer.module.scss';
|
||||
@@ -13,10 +15,12 @@ interface AuxTimerProps {
|
||||
}
|
||||
|
||||
export function AuxTimer({ index }: AuxTimerProps) {
|
||||
const { playback, direction } = useAuxTimerControl(index);
|
||||
const { playback, direction, name } = useAuxTimerControl(index);
|
||||
|
||||
const { stop, setDirection } = setAuxTimer;
|
||||
|
||||
const label = getAuxTimerIndexedLabel(name, index);
|
||||
|
||||
const toggleDirection = () => {
|
||||
const newDirection = direction === SimpleDirection.CountDown ? SimpleDirection.CountUp : SimpleDirection.CountDown;
|
||||
setDirection(index, newDirection);
|
||||
@@ -27,10 +31,12 @@ export function AuxTimer({ index }: AuxTimerProps) {
|
||||
|
||||
return (
|
||||
<label className={style.label}>
|
||||
Aux Timer {index}
|
||||
<Tooltip text={label} render={<span />} className={style.labelText}>
|
||||
{label}
|
||||
</Tooltip>
|
||||
<div className={style.controls}>
|
||||
<div className={style.input}>
|
||||
<AuxTimerInput index={index} isActive={isActive} />
|
||||
<AuxTimerInput index={index} isActive={isActive} placeholder={`Aux ${index}`} />
|
||||
<TapButton onClick={toggleDirection} aspect='tight' disabled={isActive}>
|
||||
{direction === SimpleDirection.CountDown && <IoArrowDown data-testid={`aux-timer-direction-${index}`} />}
|
||||
{direction === SimpleDirection.CountUp && <IoArrowUp data-testid={`aux-timer-direction-${index}`} />}
|
||||
@@ -50,9 +56,10 @@ export function AuxTimer({ index }: AuxTimerProps) {
|
||||
interface AuxTimerInputProps {
|
||||
index: number;
|
||||
isActive: boolean;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
|
||||
function AuxTimerInput({ index, isActive, placeholder }: AuxTimerInputProps) {
|
||||
const newTimeInMs = useAuxTimerTime(index);
|
||||
const { setDuration } = setAuxTimer;
|
||||
|
||||
@@ -70,7 +77,7 @@ function AuxTimerInput({ index, isActive }: AuxTimerInputProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={`Aux ${index}`} />
|
||||
<TimeInput submitHandler={handleTimeUpdate} name={`aux${index}`} time={newTimeInMs} placeholder={placeholder} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.tagOvertime {
|
||||
color: $playback-over;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $section-white;
|
||||
font-size: $text-body-size;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { MaybeNumber, Playback, TimerPhase } from 'ontime-types';
|
||||
import { dayInMs, millisToString } from 'ontime-utils';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import AppLink from '../../../../common/components/link/app-link/AppLink';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import useReport from '../../../../common/hooks-query/useReport';
|
||||
import { useTimer } from '../../../../common/hooks/useSocket';
|
||||
import { formatDuration } from '../../../../common/utils/time';
|
||||
import { useTimerProgress } from '../../../../common/hooks/useSocket';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { formatDuration, normaliseWallClock } from '../../../../common/utils/time';
|
||||
import TimerDisplay from '../timer-display/TimerDisplay';
|
||||
|
||||
import style from './PlaybackTimer.module.scss';
|
||||
@@ -24,15 +25,14 @@ function resolveAddedTimeLabel(addedTime: number) {
|
||||
}
|
||||
|
||||
export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
const timer = useTimer();
|
||||
'use memo';
|
||||
const timer = useTimerProgress();
|
||||
|
||||
const isRolling = timer.playback === Playback.Roll;
|
||||
const isWaiting = timer.phase === TimerPhase.Pending;
|
||||
const isOvertime = timer.phase === TimerPhase.Overtime;
|
||||
const hasAddedTime = Boolean(timer.addedTime);
|
||||
|
||||
const rollLabel = isRolling ? 'Roll mode active' : '';
|
||||
|
||||
const addedTimeLabel = resolveAddedTimeLabel(timer.addedTime);
|
||||
|
||||
return (
|
||||
@@ -51,7 +51,13 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
{isWaiting ? (
|
||||
<span className={style.rolltag}>Roll: Countdown to start</span>
|
||||
) : (
|
||||
<RunningStatus startedAt={timer.startedAt} expectedFinish={timer.expectedFinish} playback={timer.playback} />
|
||||
<RunningStatus
|
||||
startedAt={timer.startedAt}
|
||||
expectedFinish={timer.expectedFinish}
|
||||
isStopped={timer.playback === Playback.Stop}
|
||||
isCountToEnd={timer.isCountToEnd}
|
||||
isOvertime={isOvertime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
@@ -62,16 +68,18 @@ export default function PlaybackTimer({ children }: PropsWithChildren) {
|
||||
interface RunningStatusProps {
|
||||
startedAt: MaybeNumber;
|
||||
expectedFinish: MaybeNumber;
|
||||
playback: Playback;
|
||||
isStopped: boolean;
|
||||
isCountToEnd: boolean;
|
||||
isOvertime: boolean;
|
||||
}
|
||||
function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusProps) {
|
||||
if (playback === Playback.Stop) {
|
||||
|
||||
function RunningStatus({ startedAt, expectedFinish, isStopped, isCountToEnd, isOvertime }: RunningStatusProps) {
|
||||
if (isStopped) {
|
||||
return <StoppedStatus />;
|
||||
}
|
||||
|
||||
const started = millisToString(startedAt);
|
||||
const finishedMs = expectedFinish !== null ? expectedFinish % dayInMs : null;
|
||||
const finish = millisToString(finishedMs);
|
||||
const finish = millisToString(expectedFinish === null ? null : normaliseWallClock(expectedFinish));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -80,7 +88,9 @@ function RunningStatus({ startedAt, expectedFinish, playback }: RunningStatusPro
|
||||
<span className={style.time}>{started}</span>
|
||||
</span>
|
||||
<span className={style.finish}>
|
||||
<span className={style.tag}>Expect end</span>
|
||||
<span className={cx([style.tag, isOvertime && style.tagOvertime])}>
|
||||
{isCountToEnd ? 'Scheduled end' : 'Expected end'}
|
||||
</span>
|
||||
<span className={style.time}>{finish}</span>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -105,12 +105,29 @@ export default function Rundown({ order, flatOrder, entries, id, rundownMetadata
|
||||
handleCollapseGroup,
|
||||
});
|
||||
|
||||
// Jump to the current element on demand: the running event in Run mode, the edit cursor otherwise.
|
||||
// Scrolls only (no selection change) so jumping to the running event does not hijack the edit cursor.
|
||||
const jumpToCurrent = useCallback(() => {
|
||||
const targetId = editorMode === AppMode.Run ? featureData?.selectedEventId : cursor;
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open parent group if the target is inside a collapsed group
|
||||
const entry = entries[targetId];
|
||||
if (entry && 'parent' in entry) {
|
||||
expandGroup(entry.parent);
|
||||
}
|
||||
scrollToEntry(targetId);
|
||||
}, [editorMode, featureData?.selectedEventId, cursor, entries, expandGroup, scrollToEntry]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useRundownKeyboard({
|
||||
cursor,
|
||||
commands,
|
||||
clearSelectedEvents,
|
||||
setEntryCopyId,
|
||||
jumpToCurrent,
|
||||
});
|
||||
|
||||
// DND handlers
|
||||
|
||||
@@ -44,6 +44,9 @@ function EventEditorEmpty() {
|
||||
<Separator />
|
||||
<Combo keys={['PgDn']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Jump to current entry'>
|
||||
<Combo keys={[deviceAlt, 'L']} />
|
||||
</Shortcut>
|
||||
<Shortcut label='Deselect entry'>
|
||||
<Combo keys={['Esc']} />
|
||||
</Shortcut>
|
||||
|
||||
@@ -10,7 +10,7 @@ import Switch from '../../../../common/components/switch/Switch';
|
||||
import Tooltip from '../../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import { millisToDelayString } from '../../../../common/utils/dateConfig';
|
||||
import { formatTime } from '../../../../common/utils/time';
|
||||
import { formatTime, normaliseWallClock } from '../../../../common/utils/time';
|
||||
import TimeInputFlow from '../../time-input-flow/TimeInputFlow';
|
||||
|
||||
import style from '../EntryEditor.module.scss';
|
||||
@@ -68,8 +68,10 @@ function EventEditorTimes({
|
||||
};
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||
const delayedEnd = normaliseWallClock(timeEnd + delay);
|
||||
const delayLabel = hasDelay
|
||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(timeStart + delay)} → ${formatTime(timeEnd + delay)}`
|
||||
? `Event is ${millisToDelayString(delay, 'expanded')}. New schedule ${formatTime(delayedStart)} → ${formatTime(delayedEnd)}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
@@ -86,7 +88,6 @@ function EventEditorTimes({
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
delay={delay}
|
||||
countToEnd={countToEnd}
|
||||
showLabels
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface UseRundownKeyboardOptions {
|
||||
};
|
||||
clearSelectedEvents: () => void;
|
||||
setEntryCopyId: (id: EntryId | null, mode?: 'copy' | 'cut') => void;
|
||||
jumpToCurrent: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,7 @@ export function useRundownKeyboard({
|
||||
commands,
|
||||
clearSelectedEvents,
|
||||
setEntryCopyId,
|
||||
jumpToCurrent,
|
||||
}: UseRundownKeyboardOptions) {
|
||||
const scrollToEntry = useEventSelection((state) => state.scrollToEntry);
|
||||
|
||||
@@ -127,6 +129,8 @@ export function useRundownKeyboard({
|
||||
{ preventDefault: true, usePhysicalKeys: true },
|
||||
],
|
||||
|
||||
['alt + L', () => jumpToCurrent(), { preventDefault: true, usePhysicalKeys: true }],
|
||||
|
||||
[
|
||||
'alt + mod + ArrowDown',
|
||||
() => commands.moveEntry(cursor, 'down'),
|
||||
|
||||
@@ -7,6 +7,7 @@ $skip-opacity: 0.2;
|
||||
background-color: $block-bg;
|
||||
margin-block: 0.25rem;
|
||||
overflow: initial;
|
||||
position: relative;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
@@ -16,7 +17,7 @@ $skip-opacity: 0.2;
|
||||
'binder pb-actions estatus estatus'
|
||||
'binder ... ... ...';
|
||||
|
||||
grid-template-columns: $block-binder-width 3rem 1fr 3rem;
|
||||
grid-template-columns: $block-binder-width 3rem minmax(0, 1fr) 3rem;
|
||||
grid-template-rows: 0.125rem 2rem 2rem auto 0.125rem;
|
||||
align-items: center;
|
||||
padding-right: $block-clearance;
|
||||
@@ -136,6 +137,7 @@ $skip-opacity: 0.2;
|
||||
align-items: center;
|
||||
gap: $block-clearance;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eventTimers.editMode:hover {
|
||||
@@ -155,7 +157,7 @@ $skip-opacity: 0.2;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.nextTag {
|
||||
.warningMeta {
|
||||
font-size: 1rem;
|
||||
color: $orange-500;
|
||||
letter-spacing: 0.03px;
|
||||
@@ -231,6 +233,10 @@ $skip-opacity: 0.2;
|
||||
color: var(--status-color-active-override, $active-indicator);
|
||||
}
|
||||
|
||||
.statusIcon.countToEndStatus {
|
||||
color: $orange-400;
|
||||
}
|
||||
|
||||
.statusIcon.warning {
|
||||
color: $orange-500;
|
||||
}
|
||||
|
||||
@@ -115,12 +115,12 @@ function RundownEventInner({
|
||||
delay={delay}
|
||||
timeStrategy={timeStrategy}
|
||||
linkStart={linkStart}
|
||||
countToEnd={countToEnd}
|
||||
/>
|
||||
</div>
|
||||
<div className={style.titleSection}>
|
||||
<TitleEditor title={title} entryId={eventId} placeholder='Event title' className={style.eventTitle} />
|
||||
{isNext && <span className={style.nextTag}>UP NEXT</span>}
|
||||
{isNext && <span className={style.warningMeta}>UP NEXT</span>}
|
||||
{!isNext && countToEnd && <span className={style.warningMeta}>COUNT TO END</span>}
|
||||
</div>
|
||||
<EventBlockPlayback
|
||||
eventId={eventId}
|
||||
@@ -157,7 +157,13 @@ function RundownEventInner({
|
||||
<EndActionIcon action={endAction} className={style.statusIcon} />
|
||||
</Tooltip>
|
||||
<Tooltip text={`${countToEnd ? 'Count to End' : 'Count duration'}`} render={<span />}>
|
||||
<LuArrowDownToLine className={`${style.statusIcon} ${countToEnd ? style.active : style.disabled}`} />
|
||||
<LuArrowDownToLine
|
||||
className={cx([
|
||||
style.statusIcon,
|
||||
countToEnd ? style.active : style.disabled,
|
||||
countToEnd && style.countToEndStatus,
|
||||
])}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip text={automationTooltip} render={<span />}>
|
||||
<IoFlash className={automationIconClasses} />
|
||||
|
||||
+25
-2
@@ -1,10 +1,33 @@
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { formatDelay } from '../rundownEvent.utils';
|
||||
|
||||
describe('formatDelay()', () => {
|
||||
it('adds a given delay to the start time', () => {
|
||||
const timeStart = 60000; // 1 min
|
||||
const delay = 60000; // 1 min
|
||||
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||
const delay = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||
const result = formatDelay(timeStart, delay);
|
||||
expect(result).toEqual('New start 00:02');
|
||||
});
|
||||
|
||||
it('wraps negative delayed starts under midnight', () => {
|
||||
const timeStart = 1 * MILLIS_PER_MINUTE; // 00:01
|
||||
const delay = -2 * MILLIS_PER_MINUTE; // -00:02
|
||||
const result = formatDelay(timeStart, delay);
|
||||
expect(result).toEqual('New start 23:59');
|
||||
});
|
||||
|
||||
it('wraps later-day negative delays using delay as the source of truth', () => {
|
||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
||||
const delay = -(1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE); // -01:30
|
||||
const result = formatDelay(timeStart, delay);
|
||||
expect(result).toEqual('New start 23:30');
|
||||
});
|
||||
|
||||
it('displays positive delays as wall-clock time', () => {
|
||||
const timeStart = 1 * MILLIS_PER_HOUR; // 01:00
|
||||
const delay = 1 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE; // 01:30
|
||||
const result = formatDelay(timeStart, delay);
|
||||
expect(result).toEqual('New start 02:30');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { millisToString, removeTrailingZero } from 'ontime-utils';
|
||||
|
||||
import { formatDuration } from '../../../common/utils/time';
|
||||
import { formatDuration, normaliseWallClock } from '../../../common/utils/time';
|
||||
|
||||
export function formatDelay(timeStart: number, delay: number): string | undefined {
|
||||
if (!delay) return;
|
||||
|
||||
const delayedStart = Math.max(0, timeStart + delay);
|
||||
const delayedStart = normaliseWallClock(timeStart + delay);
|
||||
|
||||
const timeTag = removeTrailingZero(millisToString(delayedStart));
|
||||
return `New start ${timeTag}`;
|
||||
}
|
||||
|
||||
export function formatGap(gap: number, isNextDay: boolean) {
|
||||
if (gap === 0) {
|
||||
if (isNextDay) {
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
|
||||
.timerNote {
|
||||
width: 1.25em;
|
||||
flex: 0 0 1.25em;
|
||||
color: $blue-500;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.timerNotePlaceholder {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
color: $muted-gray;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import style from './TimeInputFlow.module.scss';
|
||||
|
||||
interface TimeInputFlowProps {
|
||||
eventId: string;
|
||||
countToEnd: boolean;
|
||||
timeStart: number;
|
||||
timeEnd: number;
|
||||
duration: number;
|
||||
@@ -22,12 +21,12 @@ interface TimeInputFlowProps {
|
||||
linkStart: boolean;
|
||||
delay: number;
|
||||
showLabels?: boolean;
|
||||
showWarnings?: boolean;
|
||||
}
|
||||
|
||||
export default memo(TimeInputFlow);
|
||||
function TimeInputFlow({
|
||||
eventId,
|
||||
countToEnd,
|
||||
timeStart,
|
||||
timeEnd,
|
||||
duration,
|
||||
@@ -35,6 +34,7 @@ function TimeInputFlow({
|
||||
linkStart,
|
||||
delay,
|
||||
showLabels,
|
||||
showWarnings = true,
|
||||
}: TimeInputFlowProps) {
|
||||
const { updateEntry, updateTimer } = useEntryActionsContext();
|
||||
|
||||
@@ -56,10 +56,6 @@ function TimeInputFlow({
|
||||
warnings.push('Over midnight');
|
||||
}
|
||||
|
||||
if (countToEnd) {
|
||||
warnings.push('Count to End');
|
||||
}
|
||||
|
||||
const hasDelay = delay !== 0;
|
||||
const isLockedEnd = timeStrategy === TimeStrategy.LockEnd;
|
||||
const isLockedDuration = timeStrategy === TimeStrategy.LockDuration;
|
||||
@@ -136,11 +132,19 @@ function TimeInputFlow({
|
||||
</TimeInputGroup>
|
||||
</div>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<Tooltip text={warnings.join(' - ')} className={style.timerNote} data-testid='event-warning' render={<span />}>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
)}
|
||||
{showWarnings &&
|
||||
(warnings.length > 0 ? (
|
||||
<Tooltip
|
||||
text={warnings.join(' - ')}
|
||||
className={style.timerNote}
|
||||
data-testid='event-warning'
|
||||
render={<span />}
|
||||
>
|
||||
<IoAlertCircleOutline />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className={`${style.timerNote} ${style.timerNotePlaceholder}`} aria-hidden='true' />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function EditorLayoutOptions() {
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Planning',
|
||||
description: 'Edit-focused list with planning stats',
|
||||
description: 'Edit focused list with planning stats',
|
||||
icon: layoutMode === EditorLayoutMode.PLANNING ? IoCheckmark : undefined,
|
||||
onClick: () => setLayoutMode(EditorLayoutMode.PLANNING),
|
||||
},
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
|
||||
.timer-container {
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Playback, TimerPhase, ViewSettings } from 'ontime-types';
|
||||
import { millisToString } from 'ontime-utils';
|
||||
|
||||
import { useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
|
||||
import { useAuxTimersName, useAuxTimersTime, useStudioTimersSocket } from '../../common/hooks/useSocket';
|
||||
import { getAuxTimerLabel } from '../../common/utils/auxTimerUtils';
|
||||
import { getOffsetState } from '../../common/utils/offset';
|
||||
import { cx } from '../../common/utils/styleUtils';
|
||||
import { useTranslation } from '../../translation/TranslationProvider';
|
||||
@@ -121,22 +122,23 @@ export default function StudioTimers({ viewSettings }: StudioTimersProps) {
|
||||
|
||||
function StudioTimersAux() {
|
||||
const auxTimer = useAuxTimersTime();
|
||||
const auxName = useAuxTimersName();
|
||||
|
||||
return (
|
||||
<div className='card' id='card-aux'>
|
||||
<div className='card__row'>
|
||||
<div>
|
||||
<div className='label'>Aux 1</div>
|
||||
<div className='label'>{getAuxTimerLabel(auxName.aux1, 'Aux 1')}</div>
|
||||
<div className='extra'>{millisToString(auxTimer.aux1)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='label center'>Aux 2</div>
|
||||
<div className='label center'>{getAuxTimerLabel(auxName.aux2, 'Aux 2')}</div>
|
||||
<div className='extra center'>{millisToString(auxTimer.aux2)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className='label right'>Aux 3</div>
|
||||
<div className='label right'>{getAuxTimerLabel(auxName.aux3, 'Aux 3')}</div>
|
||||
<div className='extra right'>{millisToString(auxTimer.aux3)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
|
||||
.timer-container {
|
||||
flex: 1;
|
||||
align-content: center;
|
||||
justify-self: center;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
@@ -125,6 +125,13 @@
|
||||
"**/*",
|
||||
"*{.ts}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "../server/src/html/",
|
||||
"to": "extraResources/html/",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -90,4 +90,14 @@ describe('test parseDatabaseModel() edge cases', () => {
|
||||
// @ts-expect-error -- we know this is wrong, testing imports outside domain
|
||||
expect(() => parseDatabaseModel('some random dataset')).toThrow();
|
||||
});
|
||||
|
||||
it('creates the aux timer names when importing a project file which predates the feature', () => {
|
||||
const oldProject = structuredClone(demoDb);
|
||||
// @ts-expect-error -- simulating a project file saved before aux timer naming existed
|
||||
delete oldProject.settings.auxTimerNames;
|
||||
|
||||
const { data } = parseDatabaseModel(oldProject);
|
||||
|
||||
expect(data.settings.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,15 @@ export function migrateSettings(jsonData: object): (Settings & { serverPort: num
|
||||
const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone(
|
||||
jsonData.settings,
|
||||
) as old_Settings;
|
||||
return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language };
|
||||
return {
|
||||
version: '4.0.0',
|
||||
serverPort,
|
||||
editorKey,
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { is } from '../../../utils/is.js';
|
||||
|
||||
@@ -23,6 +24,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||
const operatorKey = settings?.operatorKey;
|
||||
const timeFormat = settings?.timeFormat;
|
||||
const language = settings?.language;
|
||||
const auxTimerNames = sanitiseAuxTimerNames(settings?.auxTimerNames);
|
||||
const version = '4.5.0';
|
||||
db.settings = {
|
||||
version,
|
||||
@@ -30,6 +32,7 @@ export function migrateServerPort(jsonData: Partial<DatabaseModel>): {
|
||||
operatorKey,
|
||||
timeFormat,
|
||||
language,
|
||||
auxTimerNames,
|
||||
app: 'ontime',
|
||||
} as Settings;
|
||||
return { db, serverPort: settings?.serverPort };
|
||||
|
||||
@@ -184,6 +184,7 @@ describe('v3 to v4', () => {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
};
|
||||
const newSettings = v3.migrateSettings(oldDb);
|
||||
expect(newSettings).toEqual(expectSettings);
|
||||
|
||||
@@ -432,6 +432,40 @@ describe('processRundown()', () => {
|
||||
expect(initResult.totalDuration).toBe(500 - 100);
|
||||
});
|
||||
|
||||
it('skipped events do not advance day offsets or affect gaps', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', 'skipped', '2'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
||||
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
||||
}),
|
||||
skipped: makeOntimeEvent({
|
||||
id: 'skipped',
|
||||
skip: true,
|
||||
timeStart: 22 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 22:30:00
|
||||
timeEnd: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||
duration: 2 * MILLIS_PER_HOUR, // 02:00:00
|
||||
}),
|
||||
'2': makeOntimeEvent({
|
||||
id: '2',
|
||||
timeStart: 30 * MILLIS_PER_MINUTE, // 00:30:00
|
||||
timeEnd: 8 * MILLIS_PER_HOUR + 30 * MILLIS_PER_MINUTE, // 08:30:00
|
||||
duration: 8 * MILLIS_PER_HOUR, // 08:00:00
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const initResult = processRundown(rundown, {});
|
||||
|
||||
expect((initResult.entries.skipped as OntimeEvent).dayOffset).toBe(0);
|
||||
expect((initResult.entries.skipped as OntimeEvent).gap).toBe(0);
|
||||
expect((initResult.entries['2'] as OntimeEvent).dayOffset).toBe(1);
|
||||
expect((initResult.entries['2'] as OntimeEvent).gap).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates total duration across days with gap', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['1', '2', '3'],
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { EndAction, OntimeEvent, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import {
|
||||
EndAction,
|
||||
OntimeEvent,
|
||||
OntimeGroup,
|
||||
OntimeMilestone,
|
||||
TimeStrategy,
|
||||
TimerLifeCycle,
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
import { parseRundown } from '../rundown.parser.js';
|
||||
import {
|
||||
calculateDayOffset,
|
||||
deleteById,
|
||||
@@ -10,6 +20,8 @@ import {
|
||||
getIntegerAndFraction,
|
||||
hasChanges,
|
||||
makeDeepClone,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
@@ -287,3 +299,314 @@ describe('getIntegerAndFraction()', () => {
|
||||
expect(getIntegerAndFraction('123.')).toStrictEqual({ integer: 123, faction: 0, precision: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The merge strategy takes the incoming (spreadsheet) rundown as the source of truth for structure
|
||||
* and order. For a matched event it only applies the fields the spreadsheet mapped (providedFields);
|
||||
* any field it does not provide (e.g. automations) is kept from the existing event.
|
||||
*/
|
||||
describe('mergeRundownPreservingFields()', () => {
|
||||
const automation: Trigger = {
|
||||
id: 'trigger-onair',
|
||||
title: 'Go on air',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: 'automation-onair',
|
||||
};
|
||||
|
||||
it('keeps the current rundown identity but takes structure and order from the incoming rundown', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
title: 'Main show',
|
||||
revision: 3,
|
||||
order: ['welcome', 'keynote'],
|
||||
entries: {
|
||||
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote' }),
|
||||
},
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
title: 'From spreadsheet',
|
||||
revision: 0,
|
||||
order: ['welcome', 'lunch'],
|
||||
entries: {
|
||||
welcome: makeOntimeEvent({ id: 'welcome', title: 'Welcome' }),
|
||||
lunch: makeOntimeEvent({ id: 'lunch', title: 'Lunch' }),
|
||||
},
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
|
||||
// identity and revision come from the current rundown
|
||||
expect(merged.id).toBe('show-rundown');
|
||||
expect(merged.title).toBe('Main show');
|
||||
expect(merged.revision).toBe(4);
|
||||
// structure and order come from the incoming rundown
|
||||
expect(merged.order).toEqual(['welcome', 'lunch']);
|
||||
expect(merged.flatOrder).toEqual(incoming.flatOrder);
|
||||
});
|
||||
|
||||
it('deletes current entries that are absent from the incoming rundown', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['welcome', 'keynote'],
|
||||
entries: {
|
||||
welcome: makeOntimeEvent({ id: 'welcome' }),
|
||||
keynote: makeOntimeEvent({ id: 'keynote' }),
|
||||
},
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['welcome'],
|
||||
entries: { welcome: makeOntimeEvent({ id: 'welcome' }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
|
||||
expect(merged.entries.welcome).toBeDefined();
|
||||
expect(merged.entries.keynote).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replaces an entry entirely with the incoming data when the id is kept but the type changes', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [automation] }) },
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeGroup({ id: 'keynote', title: 'Keynote group' }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
|
||||
// the incoming group fully replaces the previous event, no old data is carried over
|
||||
expect(merged.entries.keynote).toEqual(incoming.entries.keynote);
|
||||
});
|
||||
|
||||
it('applies the provided fields to a matched event, including when the incoming value is empty', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'in the green room' }) },
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', note: '' }) },
|
||||
});
|
||||
|
||||
// the sheet mapped title and note
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title', 'note'], custom: [] });
|
||||
const keynote = merged.entries.keynote as OntimeEvent;
|
||||
|
||||
expect(keynote.title).toBe('Opening keynote');
|
||||
// an empty provided value replaces the current one
|
||||
expect(keynote.note).toBe('');
|
||||
});
|
||||
|
||||
it('keeps fields the sheet did not map on a matched event, regardless of the incoming values', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: {
|
||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', note: 'green room', triggers: [automation] }),
|
||||
},
|
||||
});
|
||||
// the preview always fully populates an entry, so the incoming carries a note and triggers; what
|
||||
// the sheet actually supplied is providedFields, not the values that happen to be on the entry
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: {
|
||||
keynote: makeOntimeEvent({
|
||||
id: 'keynote',
|
||||
title: 'Opening keynote',
|
||||
note: 'from a stale column',
|
||||
triggers: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// only title is mapped, so note and automations keep the existing values, not the incoming ones
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
const keynote = merged.entries.keynote as OntimeEvent;
|
||||
|
||||
expect(keynote.title).toBe('Opening keynote');
|
||||
expect(keynote.note).toBe('green room');
|
||||
expect(keynote.triggers).toEqual([automation]);
|
||||
});
|
||||
|
||||
it('patches the provided custom fields on a matched event and keeps the unmapped ones', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: {
|
||||
keynote: makeOntimeEvent({
|
||||
id: 'keynote',
|
||||
title: 'Keynote',
|
||||
custom: { lighting: 'warm', song: 'intro theme' },
|
||||
}),
|
||||
},
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: {
|
||||
keynote: makeOntimeEvent({ id: 'keynote', title: 'Opening keynote', custom: { lighting: 'cold' } }),
|
||||
},
|
||||
});
|
||||
|
||||
// the sheet mapped the title and the lighting custom field, but not song
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: ['lighting'] });
|
||||
const keynote = merged.entries.keynote as OntimeEvent;
|
||||
|
||||
expect(keynote.title).toBe('Opening keynote');
|
||||
expect(keynote.custom.lighting).toBe('cold');
|
||||
// an unmapped custom field is kept from the existing event
|
||||
expect(keynote.custom.song).toBe('intro theme');
|
||||
});
|
||||
|
||||
it('infers the time strategy from the provided times when the sheet is unambiguous', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
||||
});
|
||||
// the sheet provides only a duration, so the strategy is unambiguously LockDuration
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', duration: 60000 }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['duration'], custom: [] });
|
||||
|
||||
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockDuration);
|
||||
});
|
||||
|
||||
it('keeps the existing time strategy when the provided times are ambiguous', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeStrategy: TimeStrategy.LockEnd }) },
|
||||
});
|
||||
// the sheet provides both an end and a duration, so the strategy cannot be inferred from them
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', timeEnd: 60000, duration: 60000 }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['timeEnd', 'duration'], custom: [] });
|
||||
|
||||
expect((merged.entries.keynote as OntimeEvent).timeStrategy).toBe(TimeStrategy.LockEnd);
|
||||
});
|
||||
|
||||
it('merges a matched group, keeping the fields the sheet cannot express', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['session'],
|
||||
entries: { session: makeOntimeGroup({ id: 'session', title: 'Old session', targetDuration: 3_600_000 }) },
|
||||
});
|
||||
// the sheet cannot express a group's target duration, so the incoming group does not carry one
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['session'],
|
||||
entries: { session: makeOntimeGroup({ id: 'session', title: 'New session', targetDuration: null }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
const session = merged.entries.session as OntimeGroup;
|
||||
|
||||
expect(session.title).toBe('New session');
|
||||
expect(session.targetDuration).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('merges a matched milestone, keeping the fields the sheet did not map', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['reminder'],
|
||||
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Reminder', note: 'call talent' }) },
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['reminder'],
|
||||
entries: { reminder: makeOntimeMilestone({ id: 'reminder', title: 'Green room reminder', note: 'ignored' }) },
|
||||
});
|
||||
|
||||
// only title is mapped, so the milestone's note keeps the existing value
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
const reminder = merged.entries.reminder as OntimeMilestone;
|
||||
|
||||
expect(reminder.title).toBe('Green room reminder');
|
||||
expect(reminder.note).toBe('call talent');
|
||||
});
|
||||
|
||||
it('does not mutate the current rundown and deep-clones the kept automations', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation] }) },
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', triggers: [] }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
|
||||
// kept automations are a copy, not a shared reference to the current rundown
|
||||
(merged.entries.keynote as OntimeEvent).triggers.push({ ...automation, id: 'trigger-extra' });
|
||||
expect((current.entries.keynote as OntimeEvent).triggers).toEqual([automation]);
|
||||
});
|
||||
|
||||
it('keeps the not-provided fields through a parseRundown round-trip', () => {
|
||||
const current = makeRundown({
|
||||
id: 'show-rundown',
|
||||
order: ['keynote'],
|
||||
entries: {
|
||||
keynote: makeOntimeEvent({ id: 'keynote', triggers: [automation], timeStrategy: TimeStrategy.LockEnd }),
|
||||
},
|
||||
});
|
||||
const incoming = makeRundown({
|
||||
id: 'spreadsheet-rundown',
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', title: 'Keynote', triggers: [] }) },
|
||||
});
|
||||
|
||||
const merged = mergeRundownPreservingFields(incoming, current, { event: ['title'], custom: [] });
|
||||
const parsed = parseRundown(merged, {});
|
||||
const keynote = parsed.entries.keynote as OntimeEvent;
|
||||
|
||||
expect(keynote.triggers).toEqual([automation]);
|
||||
expect(keynote.timeStrategy).toBe(TimeStrategy.LockEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLoadedPlayable()', () => {
|
||||
it('returns true when the loaded event still exists and is playable', () => {
|
||||
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeEvent({ id: 'keynote' }) } });
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the loaded event was removed', () => {
|
||||
const rundown = makeRundown({ order: ['welcome'], entries: { welcome: makeOntimeEvent({ id: 'welcome' }) } });
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the loaded event is now skipped', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['keynote'],
|
||||
entries: { keynote: makeOntimeEvent({ id: 'keynote', skip: true }) },
|
||||
});
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the matched entry is no longer an event', () => {
|
||||
const rundown = makeRundown({ order: ['keynote'], entries: { keynote: makeOntimeGroup({ id: 'keynote' }) } });
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -279,14 +279,22 @@ function processEntry<T extends OntimeEntry>(
|
||||
// 2. handle custom fields - mutates entry
|
||||
sanitiseCustomFields(customFields, entry);
|
||||
|
||||
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
||||
entry.dayOffset = rundownMetadata.totalDays as Day;
|
||||
entry.delay = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
entry.gap = 0; // this means we dont calculate delays or gaps for skipped events
|
||||
/*
|
||||
* we initialise data so that is not calculated for skipped events
|
||||
* consider especially the day offset, while skipped events have no
|
||||
* day offset, we match it to the previous element to avoid
|
||||
* pushing confusing data to the UI
|
||||
*/
|
||||
entry.dayOffset = (rundownMetadata.previousEvent?.dayOffset ?? 0) as Day;
|
||||
entry.delay = 0;
|
||||
entry.gap = 0;
|
||||
|
||||
entry.parent = childOfGroup;
|
||||
|
||||
// update rundown metadata, it only concerns playable events
|
||||
if (isPlayableEvent(entry)) {
|
||||
rundownMetadata.totalDays += calculateDayOffset(entry, rundownMetadata.previousEvent);
|
||||
entry.dayOffset = rundownMetadata.totalDays as Day;
|
||||
rundownMetadata.playableEventOrder.push(entry.id);
|
||||
|
||||
// first start is always the first event
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { Request, Response, Router } from 'express';
|
||||
import express from 'express';
|
||||
import { matchedData } from 'express-validator';
|
||||
import { ErrorResponse, OntimeEntry, ProjectRundownsList, RenumberCues, Rundown } from 'ontime-types';
|
||||
import {
|
||||
ErrorResponse,
|
||||
OntimeEntry,
|
||||
ProjectRundowns,
|
||||
ProjectRundownsList,
|
||||
RenumberCues,
|
||||
Rundown,
|
||||
RundownImportPayload,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
@@ -12,7 +20,9 @@ import {
|
||||
applyDelay,
|
||||
batchEditEntries,
|
||||
cloneEntry,
|
||||
applyImportToRundown,
|
||||
createNewRundown,
|
||||
createRundownFromImport,
|
||||
deleteAllEntries,
|
||||
deleteEntries,
|
||||
deleteRundown,
|
||||
@@ -36,6 +46,7 @@ import {
|
||||
entryReorderValidator,
|
||||
entrySwapValidator,
|
||||
rundownArrayOfIds,
|
||||
rundownImportValidator,
|
||||
rundownPatchValidator,
|
||||
rundownPostValidator,
|
||||
} from './rundown.validation.js';
|
||||
@@ -147,6 +158,39 @@ router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRu
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Applies an imported rundown: override or merge into an existing rundown, or create a new one.
|
||||
*/
|
||||
router.post(
|
||||
'/import',
|
||||
rundownImportValidator,
|
||||
async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData<RundownImportPayload>(req);
|
||||
let projectRundowns: ProjectRundowns;
|
||||
if (mode === 'new') {
|
||||
projectRundowns = await createRundownFromImport(rundown, customFields);
|
||||
} else {
|
||||
// the validator guarantees this for override/merge, the guard narrows the type and adds defence in depth
|
||||
if (!targetRundownId) {
|
||||
throw new Error('targetRundownId is required when mode is override or merge');
|
||||
}
|
||||
projectRundowns = await applyImportToRundown(
|
||||
mode,
|
||||
targetRundownId,
|
||||
rundown,
|
||||
customFields,
|
||||
providedFields ?? { event: [], custom: [] },
|
||||
);
|
||||
}
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CustomFields,
|
||||
EntryId,
|
||||
EventPostPayload,
|
||||
ImportedFields,
|
||||
InsertOptions,
|
||||
LogOrigin,
|
||||
OntimeEntry,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
ProjectRundowns,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
RundownImportMergeStrategy,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
@@ -25,6 +27,7 @@ import { makeNewRundown } from '../../models/dataModel.js';
|
||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { parseCustomFields } from '../custom-fields/customFields.parser.js';
|
||||
import {
|
||||
createTransaction,
|
||||
customFieldMutation,
|
||||
@@ -33,8 +36,15 @@ import {
|
||||
rundownMutation,
|
||||
updateBackgroundRundown,
|
||||
} from './rundown.dao.js';
|
||||
import { parseRundown } from './rundown.parser.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, getIntegerAndFraction, hasChanges } from './rundown.utils.js';
|
||||
import {
|
||||
generateEvent,
|
||||
getIntegerAndFraction,
|
||||
hasChanges,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
} from './rundown.utils.js';
|
||||
|
||||
/**
|
||||
* creates a new entry with given data
|
||||
@@ -656,8 +666,8 @@ export async function loadRundown(id: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new rundown in the cache
|
||||
* and marks it as the currently loaded one
|
||||
* Sets a new rundown in the cache and marks it as the currently loaded one.
|
||||
* Switching to a rundown always stops playback.
|
||||
*/
|
||||
export async function initRundown(
|
||||
rundown: Readonly<Rundown>,
|
||||
@@ -679,6 +689,25 @@ export async function initRundown(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a rebuilt version of the currently loaded rundown in place.
|
||||
* Unlike switching rundowns, this maintains playback when possible
|
||||
*/
|
||||
function applyChangeToCurrentRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
||||
const loadedEvent = runtimeService.getLoadedEventId();
|
||||
if (loadedEvent && !isLoadedPlayable(loadedEvent, rundown)) {
|
||||
runtimeService.stop();
|
||||
}
|
||||
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
setImmediate(() => {
|
||||
// notifying the timer hot-reloads the playing event and keeps playback
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { timer: true, external: true, reload: true });
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNewRundown(title: string) {
|
||||
const emptyRundown = makeNewRundown();
|
||||
emptyRundown.title = title;
|
||||
@@ -743,6 +772,86 @@ export async function duplicateExistingRundown(id: string) {
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an imported rundown against the resulting custom fields, then commits those custom
|
||||
* fields. The rundown is validated before the custom-field mutation, so a payload that fails
|
||||
* validation cannot leave a partial custom-field write behind.
|
||||
* @throws if the rundown or custom fields fail validation
|
||||
*/
|
||||
async function parseImportAndCommitCustomFields(
|
||||
source: Readonly<Rundown>,
|
||||
incomingCustomFields: CustomFields,
|
||||
): Promise<Rundown> {
|
||||
const dataProvider = getDataProvider();
|
||||
const parsedCustomFields = parseCustomFields({ customFields: incomingCustomFields });
|
||||
const mergedCustomFields = { ...dataProvider.getCustomFields(), ...parsedCustomFields };
|
||||
const parsed = parseRundown(source, mergedCustomFields);
|
||||
await dataProvider.mergeIntoData({ customFields: parsedCustomFields });
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an imported rundown onto an existing rundown, keeping the existing identity while
|
||||
* taking structure and order from the incoming data.
|
||||
* - 'override' replaces all content with the incoming data
|
||||
* - 'merge' updates matched entries with only the fields the spreadsheet provided, keeping the rest
|
||||
* (e.g. automations) from the existing entry
|
||||
*
|
||||
* When targeting the loaded rundown this is treated as a change, not a switch, so playback is
|
||||
* maintained when the playing event survives.
|
||||
* @throws if the target rundown does not exist
|
||||
*/
|
||||
export async function applyImportToRundown(
|
||||
strategy: RundownImportMergeStrategy,
|
||||
targetRundownId: string,
|
||||
incomingRundown: Rundown,
|
||||
incomingCustomFields: CustomFields,
|
||||
providedFields: ImportedFields,
|
||||
): Promise<ProjectRundowns> {
|
||||
const dataProvider = getDataProvider();
|
||||
// throws if the rundown was deleted between preview and apply
|
||||
const existing = dataProvider.getRundown(targetRundownId);
|
||||
|
||||
const source =
|
||||
strategy === 'merge'
|
||||
? mergeRundownPreservingFields(incomingRundown, existing, providedFields)
|
||||
: { ...incomingRundown, id: existing.id, title: existing.title, revision: existing.revision + 1 };
|
||||
const parsed = await parseImportAndCommitCustomFields(source, incomingCustomFields);
|
||||
|
||||
if (isCurrentRundown(targetRundownId)) {
|
||||
// applying to the loaded rundown is a change, not a switch: maintain playback when possible
|
||||
applyChangeToCurrentRundown(parsed, dataProvider.getCustomFields());
|
||||
} else {
|
||||
await dataProvider.setRundown(parsed.id, parsed);
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.ProjectRundowns);
|
||||
});
|
||||
}
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new rundown from an imported rundown and loads it,
|
||||
* so the user immediately sees the imported data.
|
||||
* Loading a rundown stops playback (as with any rundown switch).
|
||||
*/
|
||||
export async function createRundownFromImport(
|
||||
incomingRundown: Rundown,
|
||||
incomingCustomFields: CustomFields,
|
||||
): Promise<ProjectRundowns> {
|
||||
const dataProvider = getDataProvider();
|
||||
|
||||
// assign a fresh id so we never collide with an existing rundown
|
||||
const parsed = await parseImportAndCommitCustomFields({ ...incomingRundown, id: generateId() }, incomingCustomFields);
|
||||
parsed.revision = 0;
|
||||
|
||||
// initRundown persists the new rundown, makes it the loaded rundown and notifies clients
|
||||
await initRundown(parsed, dataProvider.getCustomFields(), true);
|
||||
|
||||
return dataProvider.getProjectRundowns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a rundown
|
||||
* @throws if attempting to delete the loaded rundown or the last rundown in the project
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
CustomFields,
|
||||
EntryCustomFields,
|
||||
EntryId,
|
||||
ImportedFields,
|
||||
OntimeBaseEvent,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
@@ -11,12 +12,14 @@ import {
|
||||
ProjectRundown,
|
||||
ProjectRundowns,
|
||||
Rundown,
|
||||
RundownEntries,
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
isOntimeDelay,
|
||||
isOntimeEvent,
|
||||
isOntimeGroup,
|
||||
isOntimeMilestone,
|
||||
isPlayableEvent,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
createDelay,
|
||||
@@ -225,6 +228,83 @@ export function getUniqueId(rundown: Rundown): EntryId {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an entry patch containing exactly the fields the spreadsheet supplied, for both built-in
|
||||
* and custom fields. Everything the sheet did not map is left out, so applying the patch keeps the
|
||||
* existing value for those fields.
|
||||
*/
|
||||
function buildImportPatch(entry: OntimeEntry, providedFields: ImportedFields): Partial<OntimeEntry> {
|
||||
const source = entry as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const field of providedFields.event) {
|
||||
patch[field] = source[field];
|
||||
}
|
||||
if (providedFields.custom.length > 0) {
|
||||
const entryCustom = (source.custom ?? {}) as EntryCustomFields;
|
||||
const custom: EntryCustomFields = {};
|
||||
for (const key of providedFields.custom) {
|
||||
custom[key] = entryCustom[key] ?? '';
|
||||
}
|
||||
patch.custom = custom;
|
||||
}
|
||||
return patch as Partial<OntimeEntry>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges an imported rundown into an existing one
|
||||
* - the incoming rundown is the source of truth for entry identity and structure (order + grouping)
|
||||
* - a matched entry of the same type is merged field-by-field: a field the sheet provided overwrites
|
||||
* (even when empty), a field the sheet cannot express (an event's automations, a group's target
|
||||
* duration, an unmapped custom field) is kept from the existing entry
|
||||
* - a new id, or an id whose type changed, takes the incoming entry wholesale
|
||||
* - existing entries absent from the incoming rundown are dropped
|
||||
*/
|
||||
export function mergeRundownPreservingFields(
|
||||
incoming: Readonly<Rundown>,
|
||||
existing: Readonly<Rundown>,
|
||||
providedFields: ImportedFields,
|
||||
): Rundown {
|
||||
const entries: RundownEntries = {};
|
||||
|
||||
for (const [id, incomingEntry] of Object.entries(incoming.entries)) {
|
||||
const existingEntry = existing.entries[id];
|
||||
|
||||
// a new id, or one whose type changed, is not compatible for a merge: take the incoming data
|
||||
if (existingEntry === undefined || existingEntry.type !== incomingEntry.type) {
|
||||
entries[id] = incomingEntry;
|
||||
continue;
|
||||
}
|
||||
|
||||
// merge the sheet's data onto the existing entry through the canonical patch function, which
|
||||
// keeps every unmapped field and infers an event's time strategy from the provided times
|
||||
const merged = applyPatchToEntry(existingEntry, buildImportPatch(incomingEntry, providedFields));
|
||||
// grouping comes from the sheet structure, not a data column: a group owns its children, every
|
||||
// other entry knows its parent
|
||||
const structure = isOntimeGroup(incomingEntry)
|
||||
? { entries: incomingEntry.entries }
|
||||
: { parent: incomingEntry.parent };
|
||||
entries[id] = structuredClone({ ...merged, ...structure });
|
||||
}
|
||||
|
||||
return {
|
||||
id: existing.id,
|
||||
title: existing.title,
|
||||
order: [...incoming.order],
|
||||
flatOrder: [...incoming.flatOrder],
|
||||
revision: existing.revision + 1,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the currently playing event survives a change to its rundown,
|
||||
* i.e. it still exists and is playable in the new version.
|
||||
*/
|
||||
export function isLoadedPlayable(loadedEventId: EntryId, rundown: Readonly<Rundown>): boolean {
|
||||
const entry = rundown.entries[loadedEventId];
|
||||
return entry !== undefined && isOntimeEvent(entry) && isPlayableEvent(entry);
|
||||
}
|
||||
|
||||
/** List of event properties which do not need the rundown to be regenerated */
|
||||
enum RegenerateWhitelist {
|
||||
'id', // adding it for completeness, users cannot change ID
|
||||
|
||||
@@ -11,6 +11,27 @@ export const rundownPatchValidator = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const rundownImportValidator = [
|
||||
body('mode').isString().isIn(['override', 'merge', 'new']),
|
||||
body('targetRundownId')
|
||||
.if(body('mode').isIn(['override', 'merge']))
|
||||
.isString()
|
||||
.trim()
|
||||
.notEmpty()
|
||||
.withMessage('targetRundownId is required when mode is override or merge'),
|
||||
body('rundown').isObject(),
|
||||
body('rundown.entries').isObject(),
|
||||
body('rundown.order').isArray(),
|
||||
body('rundown.flatOrder').isArray(),
|
||||
body('customFields').isObject(),
|
||||
body('providedFields').optional().isObject(),
|
||||
body('providedFields.event').optional().isArray(),
|
||||
body('providedFields.event.*').isString(),
|
||||
body('providedFields.custom').optional().isArray(),
|
||||
body('providedFields.custom.*').isString(),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
|
||||
@@ -16,6 +16,36 @@ describe('parseSettings()', () => {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
});
|
||||
});
|
||||
|
||||
it('carries custom aux timer names through and pads to a length-3 array', () => {
|
||||
const result = parseSettings({
|
||||
settings: { version: '1', auxTimerNames: ['Speaker'] } as unknown as Settings,
|
||||
});
|
||||
expect(result.auxTimerNames).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('falls back to defaults when aux timer names are missing or malformed', () => {
|
||||
const result = parseSettings({
|
||||
settings: { version: '1', auxTimerNames: 'not-an-array' } as unknown as Settings,
|
||||
});
|
||||
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
});
|
||||
|
||||
it('creates the aux timer names for project files made before the feature existed', () => {
|
||||
const oldSettings = {
|
||||
version: '4.5.0',
|
||||
editorKey: null,
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
};
|
||||
|
||||
const result = parseSettings({ settings: oldSettings as Settings });
|
||||
|
||||
expect(result.auxTimerNames).toStrictEqual(['', '', '']);
|
||||
expect(result).toMatchObject({ timeFormat: '24', language: 'en' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DatabaseModel, Settings } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { getPartialProject } from '../../models/dataModel.js';
|
||||
|
||||
@@ -22,5 +23,7 @@ export function parseSettings(data: Partial<DatabaseModel>): Settings {
|
||||
operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey,
|
||||
timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat,
|
||||
language: data.settings.language ?? defaultSettings.language,
|
||||
// older project files predate this property
|
||||
auxTimerNames: sanitiseAuxTimerNames(data.settings.auxTimerNames),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { portManager } from '../../classes/port-manager/PortManager.js';
|
||||
import * as appState from '../../services/app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from '../../services/aux-timer-service/AuxTimerService.js';
|
||||
import { validateSettings, validateWelcomeDialog, validateServerPort } from './settings.validation.js';
|
||||
|
||||
export const router: Router = express.Router();
|
||||
@@ -41,6 +42,10 @@ router.post('/', validateSettings, async (req: Request, res: Response<Settings |
|
||||
|
||||
if (!deepEqual(data, settings)) {
|
||||
await getDataProvider().setSettings(data);
|
||||
// keep the runtime aux timers in sync so consumers get the new names live
|
||||
if (!deepEqual(data.auxTimerNames, settings.auxTimerNames)) {
|
||||
auxTimerService.loadNames(data.auxTimerNames);
|
||||
}
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { body } from 'express-validator';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
|
||||
@@ -27,6 +28,7 @@ export const validateSettings = [
|
||||
pinValidator('operatorKey'),
|
||||
body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'),
|
||||
body('language').isString().trim().notEmpty(),
|
||||
body('auxTimerNames').isArray().withMessage('auxTimerNames must be an array').customSanitizer(sanitiseAuxTimerNames),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { LogOrigin, SimpleDirection, SimplePlayback, runtimeStorePlaceholder } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
import serverTiming from 'server-timing';
|
||||
|
||||
import { oscServer } from './adapters/OscAdapter.js';
|
||||
@@ -26,6 +27,7 @@ import { bodyParser } from './middleware/bodyParser.js';
|
||||
import { compressedStatic } from './middleware/staticGZip.js';
|
||||
import { ONTIME_VERSION } from './ONTIME_VERSION.js';
|
||||
import { getShowWelcomeDialog } from './services/app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from './services/aux-timer-service/AuxTimerService.js';
|
||||
import * as messageService from './services/message-service/message.service.js';
|
||||
import { initialiseProject } from './services/project-service/ProjectService.js';
|
||||
import { restoreService } from './services/restore-service/restore.service.js';
|
||||
@@ -117,8 +119,9 @@ app.use(`${prefix}/external`, (req, res) => {
|
||||
app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true }));
|
||||
|
||||
// Serve legacy timer for old browsers that don't support modern JS
|
||||
// dotfiles must be allowed since the install path can contain dot directories (eg AppImage mounts in /tmp/.mount_*)
|
||||
app.get(`${prefix}/timer-legacy`, authenticateAndRedirect, (_req, res) => {
|
||||
res.sendFile(srcFiles.timerLegacy);
|
||||
res.sendFile(srcFiles.timerLegacy, { dotfiles: 'allow' });
|
||||
});
|
||||
|
||||
// Base route for static files
|
||||
@@ -203,6 +206,7 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
||||
* Module initialises the services and provides initial payload for the store
|
||||
*/
|
||||
const state = getState();
|
||||
const [auxName1, auxName2, auxName3] = sanitiseAuxTimerNames(getDataProvider().getSettings().auxTimerNames);
|
||||
eventStore.init({
|
||||
clock: state.clock,
|
||||
timer: state.timer,
|
||||
@@ -218,22 +222,28 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName1,
|
||||
},
|
||||
auxtimer2: {
|
||||
duration: timerConfig.auxTimerDefault,
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName2,
|
||||
},
|
||||
auxtimer3: {
|
||||
duration: timerConfig.auxTimerDefault,
|
||||
current: timerConfig.auxTimerDefault,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: auxName3,
|
||||
},
|
||||
ping: 1,
|
||||
});
|
||||
|
||||
// AuxTimerService owns its own SimpleTimer instances, so the store above doesn't update them
|
||||
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
|
||||
|
||||
// initialise message service
|
||||
messageService.init(eventStore.set, eventStore.get);
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('safeMerge', () => {
|
||||
editorKey: null,
|
||||
timeFormat: baseDb.settings.timeFormat,
|
||||
language: 'pt',
|
||||
auxTimerNames: baseDb.settings.auxTimerNames,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export class SimpleTimer {
|
||||
current: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
name: '',
|
||||
};
|
||||
private startedAt: number | null = null;
|
||||
private pausedAt: number | null = null;
|
||||
@@ -23,9 +24,16 @@ export class SimpleTimer {
|
||||
current: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
direction: SimpleDirection.CountDown,
|
||||
// the name is a persisted configuration, independent of the timer runtime
|
||||
name: this.state.name,
|
||||
};
|
||||
}
|
||||
|
||||
public setName(name: string): SimpleTimerState {
|
||||
this.state.name = name;
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the duration of the timer
|
||||
* @param time - time in milliseconds
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -27,6 +28,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -38,6 +40,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime - 100,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
|
||||
@@ -58,6 +61,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime - 1500,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Pause,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
|
||||
@@ -83,6 +87,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
};
|
||||
expect(newState).toStrictEqual(expected);
|
||||
});
|
||||
@@ -97,6 +102,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
};
|
||||
|
||||
newState = timer.update(100);
|
||||
@@ -127,6 +133,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1000,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(100);
|
||||
@@ -135,6 +142,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: initialTime + 100,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(500);
|
||||
@@ -143,6 +151,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.setDirection(SimpleDirection.CountDown, 600);
|
||||
@@ -151,6 +160,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(700);
|
||||
@@ -159,6 +169,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1400,
|
||||
direction: SimpleDirection.CountDown,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.setDirection(SimpleDirection.CountUp, 700);
|
||||
@@ -167,6 +178,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1400,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
|
||||
newState = timer.update(800);
|
||||
@@ -175,6 +187,7 @@ describe('SimpleTimer count-down', () => {
|
||||
current: 1500,
|
||||
direction: SimpleDirection.CountUp,
|
||||
playback: SimplePlayback.Start,
|
||||
name: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const dbModel: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
overrideStyles: false,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const demoDb: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ff7300',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
|
||||
import { sanitiseAuxTimerNames } from 'ontime-utils';
|
||||
|
||||
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
|
||||
import { timerConfig } from '../../setup/config.js';
|
||||
@@ -24,6 +25,20 @@ export class AuxTimerService {
|
||||
this.getTime = getTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called at bootstrap and whenever the loaded project's settings change,
|
||||
* so the running timers reflect the current project's aux timer names.
|
||||
*/
|
||||
loadNames(names?: string[]) {
|
||||
const [name1, name2, name3] = sanitiseAuxTimerNames(names);
|
||||
const patch: AuxTimerStateUpdate = {
|
||||
auxtimer1: this.aux1.setName(name1),
|
||||
auxtimer2: this.aux2.setName(name2),
|
||||
auxtimer3: this.aux3.setName(name3),
|
||||
};
|
||||
this.emit(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the aux timers are currently running
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { RuntimeStore } from 'ontime-types';
|
||||
|
||||
import { AuxTimerService } from '../AuxTimerService.js';
|
||||
|
||||
describe('AuxTimerService', () => {
|
||||
describe('loadNames()', () => {
|
||||
it('applies the names to each aux timer and broadcasts them', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['Speaker', 'Break', 'Q&A']);
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('Speaker');
|
||||
expect(patch.auxtimer2?.name).toBe('Break');
|
||||
expect(patch.auxtimer3?.name).toBe('Q&A');
|
||||
});
|
||||
|
||||
it('defaults missing names to an empty string', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['only-one']);
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('only-one');
|
||||
expect(patch.auxtimer2?.name).toBe('');
|
||||
expect(patch.auxtimer3?.name).toBe('');
|
||||
});
|
||||
|
||||
it('handles names missing from a project file', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
expect(() => service.loadNames(undefined)).not.toThrow();
|
||||
|
||||
const patch = emit.mock.calls.at(-1)?.[0] as Partial<RuntimeStore>;
|
||||
expect(patch.auxtimer1?.name).toBe('');
|
||||
expect(patch.auxtimer2?.name).toBe('');
|
||||
expect(patch.auxtimer3?.name).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the name on the timer through subsequent commands', () => {
|
||||
const emit = vi.fn();
|
||||
const service = new AuxTimerService(emit, () => 0);
|
||||
|
||||
service.loadNames(['Speaker', '', '']);
|
||||
const started = service.start(1);
|
||||
|
||||
expect(started.name).toBe('Speaker');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
|
||||
import { DatabaseModel, LogOrigin, ProjectFileListResponse, RefetchKey } from 'ontime-types';
|
||||
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
|
||||
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
|
||||
import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
|
||||
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
removeFileExtension,
|
||||
} from '../../utils/fileManagement.js';
|
||||
import { getLastLoaded, isLastLoadedProject, setLastLoaded } from '../app-state-service/AppStateService.js';
|
||||
import { auxTimerService } from '../aux-timer-service/AuxTimerService.js';
|
||||
import { runtimeService } from '../runtime-service/runtime.service.js';
|
||||
import {
|
||||
doesProjectExist,
|
||||
@@ -88,12 +90,16 @@ async function loadProject(projectData: DatabaseModel, fileName: string, rundown
|
||||
// stop the runtime service
|
||||
runtimeService.stop();
|
||||
|
||||
// AuxTimerService holds its own state, independent of the loaded project, so it needs to be updated explicitly
|
||||
auxTimerService.loadNames(projectData.settings.auxTimerNames);
|
||||
|
||||
// load the rundown given by key otherwise load the first in the project
|
||||
const rundown =
|
||||
rundownId && rundownId in projectData.rundowns
|
||||
? projectData.rundowns[rundownId]
|
||||
: getFirstRundown(projectData.rundowns);
|
||||
|
||||
// initialising the rundown with reload sends a refetch to the clients
|
||||
await initRundown(rundown, projectData.customFields, true);
|
||||
|
||||
// persist the project selection
|
||||
@@ -346,6 +352,12 @@ export async function patchCurrentProject(data: Partial<DatabaseModel>) {
|
||||
// we can pass some stuff straight to the data provider
|
||||
await getDataProvider().mergeIntoData(rest);
|
||||
|
||||
// AuxTimerService holds its own state, so a settings patch needs to be applied to it explicitly
|
||||
if (rest.settings) {
|
||||
auxTimerService.loadNames(getDataProvider().getSettings().auxTimerNames);
|
||||
sendRefetch(RefetchKey.Settings);
|
||||
}
|
||||
|
||||
// the rundown depends on custom fields
|
||||
// so custom fields needs to be checked first
|
||||
if (customFields) {
|
||||
|
||||
@@ -165,6 +165,13 @@ class RuntimeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the id of the currently loaded event, or null if none is loaded
|
||||
*/
|
||||
public getLoadedEventId(): EntryId | null {
|
||||
return runtimeState.getState().eventNow?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the underlying data has changed,
|
||||
* we check if the change affects the runtime
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.10.0",
|
||||
"version": "4.11.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -18,3 +18,44 @@ export type SpreadsheetPreviewResponse = {
|
||||
customFields: CustomFields;
|
||||
summary: RundownSummary;
|
||||
};
|
||||
|
||||
/**
|
||||
* How elements matched by id should be reconciled when importing into an existing rundown
|
||||
* - override: imported data replaces the whole matched element
|
||||
* - merge: imported data updates only the fields the spreadsheet supplied, keeping the rest
|
||||
*/
|
||||
export type RundownImportMergeStrategy = 'override' | 'merge';
|
||||
|
||||
/**
|
||||
* What an import does with the data
|
||||
* - override / merge apply the import onto an existing rundown (reconciling matched elements)
|
||||
* - new creates a fresh rundown from the import
|
||||
*/
|
||||
export type RundownImportMode = RundownImportMergeStrategy | 'new';
|
||||
|
||||
/**
|
||||
* The fields an import supplies, i.e. the columns the spreadsheet maps.
|
||||
* This is the complete description of what the incoming data provides, so a merge can apply exactly
|
||||
* these fields onto a matched event and keep everything else (e.g. automations) untouched.
|
||||
*/
|
||||
export type ImportedFields = {
|
||||
/** mapped OntimeEvent fields (the import-map keys are OntimeEvent field names) */
|
||||
event: string[];
|
||||
/** mapped custom field keys */
|
||||
custom: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload for the rundown import endpoint
|
||||
* - override / merge apply the import onto the target rundown (targetRundownId required)
|
||||
* - new creates a fresh rundown from the import
|
||||
*/
|
||||
export type RundownImportPayload = {
|
||||
mode: RundownImportMode;
|
||||
/** required when mode is 'override' or 'merge' */
|
||||
targetRundownId?: string;
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
/** the fields the spreadsheet supplies; used by 'merge' to patch only those on a matched event */
|
||||
providedFields?: ImportedFields;
|
||||
};
|
||||
|
||||
@@ -6,4 +6,9 @@ export type Settings = {
|
||||
operatorKey: null | string;
|
||||
timeFormat: TimeFormat;
|
||||
language: string;
|
||||
/**
|
||||
* Custom names for the aux timers, one entry per aux timer in order (index 0 is aux timer 1).
|
||||
* An empty string means the timer is unnamed and consumers show the default label
|
||||
*/
|
||||
auxTimerNames: string[];
|
||||
};
|
||||
|
||||
@@ -14,4 +14,6 @@ export type SimpleTimerState = {
|
||||
current: number;
|
||||
playback: SimplePlayback;
|
||||
direction: SimpleDirection;
|
||||
/** Custom name for the aux timer. Empty string when unnamed */
|
||||
name: string;
|
||||
};
|
||||
|
||||
@@ -53,18 +53,21 @@ export const runtimeStorePlaceholder: Readonly<RuntimeStore> = {
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
auxtimer2: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
auxtimer3: {
|
||||
current: 0,
|
||||
direction: SimpleDirection.CountUp,
|
||||
duration: 0,
|
||||
playback: SimplePlayback.Stop,
|
||||
name: '',
|
||||
},
|
||||
ping: 1,
|
||||
};
|
||||
|
||||
@@ -97,6 +97,10 @@ export type {
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
SpreadsheetPreviewResponse,
|
||||
RundownImportMergeStrategy,
|
||||
RundownImportMode,
|
||||
RundownImportPayload,
|
||||
ImportedFields,
|
||||
} from './api/spreadsheet/Spreadsheet.type.js';
|
||||
|
||||
// web socket
|
||||
|
||||
@@ -99,6 +99,9 @@ export {
|
||||
|
||||
export { isPlaybackActive } from './src/playback-utils/playbackstate.js';
|
||||
|
||||
// aux timers
|
||||
export { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './src/aux-timer-utils/auxTimerUtils.js';
|
||||
|
||||
//Colour
|
||||
export {
|
||||
colourToHex,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { auxTimerNameMaxLength, sanitiseAuxTimerNames } from './auxTimerUtils.js';
|
||||
|
||||
describe('sanitiseAuxTimerNames()', () => {
|
||||
it('generates the default value when given nothing', () => {
|
||||
expect(sanitiseAuxTimerNames()).toStrictEqual(['', '', '']);
|
||||
});
|
||||
|
||||
it('always returns exactly three entries', () => {
|
||||
expect(sanitiseAuxTimerNames(['a', 'b', 'c', 'extra'])).toStrictEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('pads missing entries with an empty string', () => {
|
||||
expect(sanitiseAuxTimerNames(['Speaker'])).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitiseAuxTimerNames([' Speaker ', '', ''])).toStrictEqual(['Speaker', '', '']);
|
||||
});
|
||||
|
||||
it('caps the name length', () => {
|
||||
const tooLong = 'a'.repeat(auxTimerNameMaxLength + 10);
|
||||
expect(sanitiseAuxTimerNames([tooLong])[0]).toHaveLength(auxTimerNameMaxLength);
|
||||
});
|
||||
|
||||
it('falls back to defaults for malformed data', () => {
|
||||
expect(sanitiseAuxTimerNames('not-an-array')).toStrictEqual(['', '', '']);
|
||||
expect(sanitiseAuxTimerNames(null)).toStrictEqual(['', '', '']);
|
||||
expect(sanitiseAuxTimerNames([42, {}, undefined])).toStrictEqual(['', '', '']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Maximum length of a user given aux timer name */
|
||||
export const auxTimerNameMaxLength = 30;
|
||||
|
||||
function sanitiseAuxTimerName(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim().slice(0, auxTimerNameMaxLength) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ontime has three aux timers. Given whatever was found on disk or in a request body,
|
||||
* returns a name for each of them, so callers never need to deal with a missing
|
||||
* or malformed auxTimerNames (eg. a project file saved before this feature existed).
|
||||
*/
|
||||
export function sanitiseAuxTimerNames(names?: unknown): [string, string, string] {
|
||||
const source = Array.isArray(names) ? names : [];
|
||||
return [sanitiseAuxTimerName(source[0]), sanitiseAuxTimerName(source[1]), sanitiseAuxTimerName(source[2])];
|
||||
}
|
||||
@@ -342,6 +342,7 @@ export const demoDb: DatabaseModel = {
|
||||
operatorKey: null,
|
||||
timeFormat: '24',
|
||||
language: 'en',
|
||||
auxTimerNames: ['', '', ''],
|
||||
},
|
||||
viewSettings: {
|
||||
dangerColor: '#ff7300',
|
||||
|
||||
Reference in New Issue
Block a user