diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts index 1f1c07b72..6e3be1907 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/__test__/importMapUtils.test.ts @@ -1,12 +1,13 @@ import { ImportCustom } from 'ontime-utils'; -import { convertToImportMap } from '../importMapUtils'; +import { convertToImportMap, NamedImportMap } from '../importMapUtils'; describe('convertToImportMap', () => { it('converts a namedImportMap to a importMap', () => { const defaultNamedImporMap = { Worksheet: 'event schedule', Start: 'time start', + 'Link start': 'link start', End: 'time end', Duration: 'duration', Cue: 'cue', @@ -26,7 +27,7 @@ describe('convertToImportMap', () => { { ontimeName: 'EmptyImportName', importName: '' }, { ontimeName: '', importName: 'EmptyOntimeName' }, ] as ImportCustom[], - }; + } as NamedImportMap; const importMap = convertToImportMap(defaultNamedImporMap); expect(importMap.custom).toStrictEqual({ diff --git a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts index e287596e1..a3fb75715 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts +++ b/apps/client/src/features/app-settings/panel/sources-panel/import-map/importMapUtils.ts @@ -6,6 +6,7 @@ export type NamedImportMap = typeof namedImportMap; export const namedImportMap = { Worksheet: 'event schedule', Start: 'time start', + 'Link start': 'link start', End: 'time end', Duration: 'duration', Cue: 'cue', @@ -21,6 +22,15 @@ export const namedImportMap = { custom: [] as ImportCustom[], }; +function isNamedImportMap(obj: unknown): obj is NamedImportMap { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const keys = Object.keys(namedImportMap); + return keys.every((key) => Object.hasOwn(obj, key)); +} + export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap { const custom = namedImportMap.custom.reduce((accumulator, { ontimeName, importName }) => { if (ontimeName && importName) { @@ -32,6 +42,7 @@ export function convertToImportMap(namedImportMap: NamedImportMap): ImportMap { return { worksheet: namedImportMap.Worksheet, timeStart: namedImportMap.Start, + linkStart: namedImportMap['Link start'], timeEnd: namedImportMap.End, duration: namedImportMap.Duration, cue: namedImportMap.Cue, @@ -52,10 +63,22 @@ export function persistImportMap(options: NamedImportMap) { localStorage.setItem('ontime-import-options', JSON.stringify(options)); } -export function getPersistedOptions(): NamedImportMap { +function getPersistImportMap(): unknown { const options = localStorage.getItem('ontime-import-options'); if (!options) { - return namedImportMap; + throw new Error('no import options found'); } return JSON.parse(options); } + +export function getPersistedOptions(): NamedImportMap { + try { + const options = getPersistImportMap(); + if (!isNamedImportMap(options)) { + return namedImportMap; + } + return options; + } catch { + return namedImportMap; + } +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss index 4f494f378..51364e155 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.module.scss @@ -1,12 +1,27 @@ - .center { - text-align: center; - } +.center { + text-align: center; +} - .nowrap { - white-space: nowrap; - } +.nowrap { + white-space: nowrap; +} - tr .secondaryRow { - background-color: $white-7; - padding-left: 2em; - } +tr .secondaryRow { + background-color: $white-7; + padding-left: 2em; +} + +.linkStartActive { + color: $active-indicator; + transform: rotate(-45deg); +} + +.flex { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.subdued { + opacity: $opacity-disabled; +} diff --git a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx index 88d63de0a..0a53989cf 100644 --- a/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx +++ b/apps/client/src/features/app-settings/panel/sources-panel/preview/PreviewRundown.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react'; +import { IoLink } from '@react-icons/all-files/io5/IoLink'; import { CustomFields, isOntimeBlock, isOntimeEvent, OntimeRundown } from 'ontime-types'; import { millisToString } from 'ontime-utils'; @@ -83,7 +84,10 @@ export default function PreviewRundown(props: PreviewRundownProps) { {event.cue} {event.title} - {millisToString(event.timeStart)} + + {millisToString(event.timeStart)} + {event.linkStart && } + {millisToString(event.timeEnd)} {millisToString(event.duration)} {millisToString(event.timeWarning)} diff --git a/apps/server/src/utils/__tests__/parser.test.ts b/apps/server/src/utils/__tests__/parser.test.ts index caf58ea00..809226c94 100644 --- a/apps/server/src/utils/__tests__/parser.test.ts +++ b/apps/server/src/utils/__tests__/parser.test.ts @@ -19,6 +19,8 @@ import { dbModel } from '../../models/dataModel.js'; import { createEvent, getCustomFieldData, parseExcel, parseJson } from '../parser.js'; import { makeString } from '../parserUtils.js'; import { parseRundown, parseUrlPresets, parseViewSettings } from '../parserFunctions.js'; +import { ImportMap } from 'ontime-utils'; +import * as cache from '../../services/rundown-service/rundownCache.js'; describe('test json parser with valid def', () => { const testData: Partial = { @@ -734,6 +736,7 @@ describe('getCustomFieldData()', () => { const importMap = { worksheet: 'event schedule', timeStart: 'time start', + linkStart: 'link start', timeEnd: 'time end', duration: 'duration', cue: 'cue', @@ -751,7 +754,7 @@ describe('getCustomFieldData()', () => { sound: 'sound', video: 'av', }, - }; + } as ImportMap; const result = getCustomFieldData(importMap); expect(result.customFields).toStrictEqual({ @@ -1440,4 +1443,69 @@ describe('parseExcel()', () => { expect(events.at(0).title).toEqual('A song from the hearth'); //<--leading white space in Excel data expect(events.at(0).colour).toEqual('#F00'); //<--trailing white space in Excel data }); + + it('link start', () => { + const testData = [ + [ + 'Time Start', + 'Time End', + 'Title', + 'End Action', + 'Public', + 'Skip', + 'Notes', + 'Colour', + 'cue', + 'Link Start', + 'Timer type', + ], + ['4:30:00', '9:45:00', 'A', 'load-next', '', '', 'Rainbow chase', '#F00', 102, '', 'count-down'], + ['9:45:00', '10:56:00', 'C', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], + ['10:00:00', '16:36:00', 'D', 'load-next', 'x', '', 'Rainbow chase', '#F00', 102, 'x', 'count-down'], //<-- incorrect start times are overridden + ['21:45:00', '22:56:00', 'E', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, '', 'count-down'], + ['', '', 'BLOCK', '', '', '', '', '', '', '', 'block'], + ['00:0:00', '23:56:00', 'G', 'load-next', 'x', '', 'Rainbow chase', '#0F0', 103, 'x', 'count-down'], //<-- link past blocks + [], + ]; + + const importMap = { + worksheet: 'event schedule', + timeStart: 'time start', + linkStart: 'link start', + timeEnd: 'time end', + duration: 'duration', + cue: 'cue', + title: 'title', + isPublic: 'public', + skip: 'skip', + note: 'notes', + colour: 'colour', + endAction: 'end action', + timerType: 'timer type', + timeWarning: 'warning time', + timeDanger: 'danger time', + custom: {}, + }; + + const result = parseExcel(testData, importMap); + const rundown = parseRundown(result); + + cache.init(rundown, {}); + const cachedRundown = cache.get().rundown; + + const events = Object.values(cachedRundown).filter((e) => e.type === SupportedEvent.Event) as OntimeEvent[]; + + expect(events.at(0).timeStart).toEqual(16200000); + + expect(events.at(1).timeStart).toEqual(events.at(0).timeEnd); + expect(events.at(1).linkStart).toEqual(events.at(0).id); + + expect(events.at(2).timeStart).toEqual(events.at(1).timeEnd); + expect(events.at(2).linkStart).toEqual(events.at(1).id); + + expect(events.at(3).timeStart).toEqual(78300000); + + expect(events.at(4).timeStart).toEqual(events.at(3).timeEnd); + expect(events.at(4).linkStart).toEqual(events.at(3).id); + }); }); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 64e2c5f92..d142531a6 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -34,7 +34,6 @@ import { parseViewSettings, } from './parserFunctions.js'; import { parseExcelDate } from './time.js'; -import { coerceBoolean } from './coerceType.js'; export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; export const JSON_MIME = 'application/json'; @@ -43,6 +42,14 @@ type ExcelData = Pick & { rundownMetadata: Record; }; +function parseBooleanString(value: unknown): boolean { + // falsy values would be nullish or empty string + if (!value || typeof value !== 'string') { + return false; + } + return value.toLowerCase() !== 'false'; +} + export function getCustomFieldData(importMap: ImportMap): { customFields: CustomFields; customFieldImportKeys: Record; @@ -91,6 +98,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial) let isPublicIndex: number | null = null; let skipIndex: number | null = null; + let linkStartIndex: number | null = null; + // times: numbers let timeStartIndex: number | null = null; let timeEndIndex: number | null = null; @@ -116,6 +125,10 @@ export const parseExcel = (excelData: unknown[][], options?: Partial) timeStartIndex = col; rundownMetadata['timeStart'] = { row, col }; }, + [importMap.linkStart]: (row: number, col: number) => { + linkStartIndex = col; + rundownMetadata['linkStart'] = { row, col }; + }, [importMap.timeEnd]: (row: number, col: number) => { timeEndIndex = col; rundownMetadata['timeEnd'] = { row, col }; @@ -191,6 +204,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial) event.title = makeString(column, ''); } else if (j === timeStartIndex) { event.timeStart = parseExcelDate(column); + } else if (j === linkStartIndex) { + event.linkStart = parseBooleanString(column); } else if (j === timeEndIndex) { event.timeEnd = parseExcelDate(column); } else if (j === durationIndex) { @@ -198,9 +213,9 @@ export const parseExcel = (excelData: unknown[][], options?: Partial) } else if (j === cueIndex) { event.cue = makeString(column, ''); } else if (j === isPublicIndex) { - event.isPublic = column == 'x' ? true : coerceBoolean(column); + event.isPublic = parseBooleanString(column); } else if (j === skipIndex) { - event.skip = column == 'x' ? true : coerceBoolean(column); + event.skip = parseBooleanString(column); } else if (j === notesIndex) { event.note = makeString(column, ''); } else if (j === endActionIndex) { diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index d50bcbe20..bff730f56 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -18,7 +18,7 @@ import { isOntimeDelay, isOntimeEvent, } from 'ontime-types'; -import { generateId } from 'ontime-utils'; +import { generateId, getLastEvent } from 'ontime-utils'; import { dbModel } from '../models/dataModel.js'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; @@ -48,11 +48,16 @@ export const parseRundown = (data: Partial): OntimeRundown => { let newEvent: OntimeEvent | OntimeDelay | OntimeBlock | null; if (isOntimeEvent(event)) { + if (event.linkStart) { + const prevEvent = getLastEvent(rundown).lastEvent; + event.linkStart = prevEvent.id; + } newEvent = createEvent(event, eventIndex.toString()); // skip if event is invalid if (newEvent == null) { continue; } + eventIndex += 1; } else if (isOntimeDelay(event)) { newEvent = { ...delayDef, duration: event.duration, id }; diff --git a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts index 157fd989a..2ef3c5005 100644 --- a/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts +++ b/packages/utils/src/feature/spreadsheet-import/__tests__/spreadsheetImport.test.ts @@ -1,3 +1,4 @@ +import type { ImportMap } from '../spreadsheetImport'; import { isImportMap } from '../spreadsheetImport'; describe('isImportMap()', () => { @@ -5,6 +6,7 @@ describe('isImportMap()', () => { const v3ImportMap = { worksheet: 'event schedule', timeStart: 'time start', + linkStart: 'link start', timeEnd: 'time end', duration: 'duration', cue: 'cue', @@ -18,7 +20,7 @@ describe('isImportMap()', () => { timeWarning: 'warning time', timeDanger: 'danger time', custom: {}, - }; + } as ImportMap; expect(isImportMap(v3ImportMap)).toBe(true); }); @@ -27,6 +29,7 @@ describe('isImportMap()', () => { const v3ImportMap = { worksheet: 'event schedule', timeStart: 'time start', + linkStart: 'link start', timeEnd: 'time end', duration: 'duration', cue: 'cue', @@ -43,7 +46,7 @@ describe('isImportMap()', () => { userDefined: 'userDefined', anotherOne: 'anotherOne', }, - }; + } as ImportMap; expect(isImportMap(v3ImportMap)).toBe(true); }); diff --git a/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts index 8b6394f31..c8ec35a34 100644 --- a/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts +++ b/packages/utils/src/feature/spreadsheet-import/spreadsheetImport.ts @@ -6,6 +6,7 @@ export type ImportMap = typeof defaultImportMap & { custom: ImportCustom }; export const defaultImportMap = { worksheet: 'event schedule', timeStart: 'time start', + linkStart: 'link start', timeEnd: 'time end', duration: 'duration', cue: 'cue',