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',