diff --git a/apps/client/src/common/api/rundown.ts b/apps/client/src/common/api/rundown.ts index 1f9d376f8..66965eb20 100644 --- a/apps/client/src/common/api/rundown.ts +++ b/apps/client/src/common/api/rundown.ts @@ -6,6 +6,7 @@ import { ProjectRundownsList, RenumberCues, Rundown, + RundownImportPayload, TransientEventPayload, } from 'ontime-types'; @@ -82,6 +83,15 @@ export async function deleteRundown(rundownId: RundownId): Promise> { + return axios.post(`${rundownPath}/import`, payload); +} + // #endregion operations on project rundowns ====================== // #region operations on rundown entries ========================== diff --git a/apps/client/src/common/utils/__tests__/uploadUtils.test.ts b/apps/client/src/common/utils/__tests__/uploadUtils.test.ts new file mode 100644 index 000000000..942333a95 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/uploadUtils.test.ts @@ -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(''); + }); +}); diff --git a/apps/client/src/common/utils/uploadUtils.ts b/apps/client/src/common/utils/uploadUtils.ts index 6cfd7c39d..5c091c600 100644 --- a/apps/client/src/common/utils/uploadUtils.ts +++ b/apps/client/src/common/utils/uploadUtils.ts @@ -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'); } diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx index 9a05388a5..6a90e15f0 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/SourcesPanel.tsx @@ -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(null); const { data: currentRundown } = useRundown(); - const { importRundown } = useSpreadsheetImport(); + const { applyImport } = useSpreadsheetImport(); const fileInputRef = useRef(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 ( @@ -287,6 +309,7 @@ export default function SourcesPanel() { bodyElements={ 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 ( + + ); +} diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss index 8e5d26081..bf6fd8f01 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.module.scss @@ -33,6 +33,11 @@ white-space: nowrap; } +.importModeTrigger { + min-width: 12rem; + justify-content: space-between; +} + .addColumnTrigger { justify-content: center; white-space: nowrap; diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx index 895c9a006..7deb7d997 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/SheetImportEditor.tsx @@ -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; previewImport: (importMap: ImportMap) => Promise; - onApply: (preview: SpreadsheetPreviewResponse) => Promise; + onApply: ( + preview: SpreadsheetPreviewResponse, + mode: RundownImportMode, + newRundownTitle: string, + providedFields: ImportedFields, + ) => Promise; onCancel: () => void; onExport?: (importMap: ImportMap) => Promise; } +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( + () => + importModeOptions.map((option) => ({ + type: 'item', + label: option.label, + description: option.description, + icon: importMode === option.value ? IoCheckmark : undefined, + onClick: () => setImportMode(option.value), + })), + [importMode, setImportMode], + ); + return ( @@ -107,33 +164,63 @@ export default function SheetImportEditor({ {displayError && {displayError}} - - - {onExport && ( - - )} - - + {onExport && ( + + )} + + + ); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/importMapUtils.test.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/importMapUtils.test.ts index 2c4b7021a..653879d9a 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/importMapUtils.test.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/__tests__/importMapUtils.test.ts @@ -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'); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts index cec47010d..c891e8b99 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/importMapUtils.ts @@ -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>( (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. */ diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts index 8f8dc4071..b1b05cc55 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/sheet-import/useSheetImportForm.ts @@ -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; previewImport: (importMap: ReturnType) => Promise; - onApply: (preview: SpreadsheetPreviewResponse) => Promise; + onApply: ( + preview: SpreadsheetPreviewResponse, + mode: RundownImportMode, + newRundownTitle: string, + providedFields: ImportedFields, + ) => Promise; onExport?: (importMap: ReturnType) => Promise; } 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(() => getPersistedImportMode(sourceKey)); + const [newRundownTitle, setNewRundownTitle] = useState(defaultRundownName); const warnings = getImportWarnings(values, headers); const warningCount = Object.values(warnings).filter(Boolean).length; const previewRef = useRef(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, diff --git a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts index 01819e212..2dea238f8 100644 --- a/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts +++ b/apps/client/src/features/app-settings/panel/manage-panel/sources-panel/useSpreadsheetImport.ts @@ -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, }; } diff --git a/apps/client/src/views/editor/EditorLayoutOptions.tsx b/apps/client/src/views/editor/EditorLayoutOptions.tsx index 44425002b..21e3bd328 100644 --- a/apps/client/src/views/editor/EditorLayoutOptions.tsx +++ b/apps/client/src/views/editor/EditorLayoutOptions.tsx @@ -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), }, diff --git a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts index 5a4ce36d0..faae100e0 100644 --- a/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts +++ b/apps/server/src/api-data/rundown/__tests__/rundown.utils.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/api-data/rundown/rundown.router.ts b/apps/server/src/api-data/rundown/rundown.router.ts index eb920b2c9..69848db76 100644 --- a/apps/server/src/api-data/rundown/rundown.router.ts +++ b/apps/server/src/api-data/rundown/rundown.router.ts @@ -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) => { + try { + const { mode, targetRundownId, rundown, customFields, providedFields } = matchedData(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 ========================== diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index 60295eadd..91aa4ee33 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -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, @@ -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, customFields: Readonly) { + 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, + incomingCustomFields: CustomFields, +): Promise { + 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 { + 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 { + 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 diff --git a/apps/server/src/api-data/rundown/rundown.utils.ts b/apps/server/src/api-data/rundown/rundown.utils.ts index 1999da107..62b2531fb 100644 --- a/apps/server/src/api-data/rundown/rundown.utils.ts +++ b/apps/server/src/api-data/rundown/rundown.utils.ts @@ -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 { + const source = entry as Record; + const patch: Record = {}; + 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; +} + +/** + * 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, + existing: Readonly, + 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): 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 diff --git a/apps/server/src/api-data/rundown/rundown.validation.ts b/apps/server/src/api-data/rundown/rundown.validation.ts index 1ef62aaed..535a1920a 100644 --- a/apps/server/src/api-data/rundown/rundown.validation.ts +++ b/apps/server/src/api-data/rundown/rundown.validation.ts @@ -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 ========================== diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index 4c44ae858..ee6d0de58 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -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 diff --git a/packages/types/src/api/spreadsheet/Spreadsheet.type.ts b/packages/types/src/api/spreadsheet/Spreadsheet.type.ts index 8667f8a00..cea9c0e64 100644 --- a/packages/types/src/api/spreadsheet/Spreadsheet.type.ts +++ b/packages/types/src/api/spreadsheet/Spreadsheet.type.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; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d56bbde2d..77e98fd91 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -97,6 +97,10 @@ export type { SpreadsheetWorksheetMetadata, SpreadsheetWorksheetOptions, SpreadsheetPreviewResponse, + RundownImportMergeStrategy, + RundownImportMode, + RundownImportPayload, + ImportedFields, } from './api/spreadsheet/Spreadsheet.type.js'; // web socket