mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +00:00
Link start times from excel import (#903)
* add test for linking start time in excel import * link ids * add link to UI * ensure times with cachedRundown --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me>
This commit is contained in:
committed by
GitHub
parent
d8a8f0ad92
commit
74418ce17e
+3
-2
@@ -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({
|
||||
|
||||
+25
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-10
@@ -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;
|
||||
}
|
||||
|
||||
+5
-1
@@ -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) {
|
||||
</td>
|
||||
<td className={style.nowrap}>{event.cue}</td>
|
||||
<td>{event.title}</td>
|
||||
<td>{millisToString(event.timeStart)}</td>
|
||||
<td className={style.flex}>
|
||||
<span className={event.linkStart ? style.subdued : undefined}>{millisToString(event.timeStart)}</span>
|
||||
{event.linkStart && <IoLink className={style.linkStartActive} />}
|
||||
</td>
|
||||
<td>{millisToString(event.timeEnd)}</td>
|
||||
<td>{millisToString(event.duration)}</td>
|
||||
<td>{millisToString(event.timeWarning)}</td>
|
||||
|
||||
@@ -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<DatabaseModel> = {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DatabaseModel, 'rundown' | 'customFields'> & {
|
||||
rundownMetadata: Record<string, { row: number; col: number }>;
|
||||
};
|
||||
|
||||
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<keyof CustomFields, string>;
|
||||
@@ -91,6 +98,8 @@ export const parseExcel = (excelData: unknown[][], options?: Partial<ImportMap>)
|
||||
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<ImportMap>)
|
||||
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<ImportMap>)
|
||||
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<ImportMap>)
|
||||
} 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) {
|
||||
|
||||
@@ -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<DatabaseModel>): 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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user