mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 10:53:51 +00:00
feat(import): add merge strategy and new-rundown destination to spreadsheet import
Co-authored-by: Carlos Valente <34649812+cpvalente@users.noreply.github.com>
This commit is contained in:
@@ -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 ==========================
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ==========================
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -97,6 +97,10 @@ export type {
|
||||
SpreadsheetWorksheetMetadata,
|
||||
SpreadsheetWorksheetOptions,
|
||||
SpreadsheetPreviewResponse,
|
||||
RundownImportMergeStrategy,
|
||||
RundownImportMode,
|
||||
RundownImportPayload,
|
||||
ImportedFields,
|
||||
} from './api/spreadsheet/Spreadsheet.type.js';
|
||||
|
||||
// web socket
|
||||
|
||||
Reference in New Issue
Block a user