mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 18:33:53 +00:00
Alpha 5 (#1741)
* refactor: align header columns * refactor: improve scrollbar visibility * refactor: center align table elements * refactor: make param elements stateful * fix: issue with collapsed elements not loosing value * fix: prevent search params containing multiple alias references * fix: the issue where a file disappears if it is both migrated and recovered in the same load operation (#1744) * refactor: disable group action for elements in groups * refactor: move context menu items into the event element (#1747) * feat: sheet import new features for v4 (#1730) * import milestone * fixup! import milestone * test: milestone import * add entries to group stop on new group or on group-end type * fixup! add entries to group * cleanup * add event target duration * link start if undefined * add skip import type * extract some to the excel paresing functions * tweaks to presentation * move file --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * fix: notify runtimeStore of events bieng groupd * fix: improve authentication and stage detection in demo * chore: ship logo with project * fix: client is referenced by name * fix: prevent reflow in event editor * fix: stale render on selected event due to ref mismatch * Create/Load/Delete multiple rundowns (#1696) * refactor: restore last loaded rundown * refactor: initialise rundown in ProjectService * feat: allow switching rundowns ensure on coordination between the db object and the working object server provide list of rundowns implement switch in the UI implement delete implement new rundown button * fix: render order for floating button * refactor: appropriate names to service * refactor: rundown endpoints * refactor: save last loaded rundown ID * refactor: rundown management UI * refactor: emit refetch all on project load --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * feat: recover single event subscription * fixup! feat: sheet import new features for v4 (#1730) * fix: prevent dropping a group inside another * fixup! refactor: move context menu items into the event element (#1747) * fix: prevent stale references to custom fields * fix: propagate updates to all rundowns * refactor: client rundown metadata (#1728) * generate metadata in the hook * move test * ensure there is always a last element * use for-loop * update metadata in useEfect * fully extract metadata generation * use direct assignment * cleanup --------- Co-authored-by: Carlos Valente <carlosvalente@pm.me> * refactor: small imporvement and tests for coerce functions (#1752) * refactor: small imporvement and tests for coerce functions add test `coerceString` add test `coerceBoolean` add test `coerceColour` * remove old todo * fix: consistent quick add behaviour * refactor: create flat rundown with metadata * fix: show add buttons on top * feat: allow editing milestones * refactor: style tweaks to rundown elements refactor: milestones are full width refactor: cuesheet header alignment fix: editor styling in cuesheet * refactor: virtualise table * refactor: improve overscan (#1758) * bump version to 4.0.0-alpha.5 --------- Co-authored-by: Alex Christoffer Rasmussen <ac@omnivox.dk>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { CustomFields, OntimeEvent, SupportedEntry, TimerType } from 'ontime-types';
|
||||
import { defaultImportMap, ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { CustomFields, OntimeEvent, OntimeGroup, SupportedEntry, TimerType } from 'ontime-types';
|
||||
import { ImportMap, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import { getCustomFieldData, parseExcel } from '../excel.parser.js';
|
||||
import { parseExcel } from '../excel.parser.js';
|
||||
|
||||
import { dataFromExcelTemplate } from './mockData.js';
|
||||
|
||||
@@ -156,11 +156,38 @@ describe('parseExcel()', () => {
|
||||
expect((firstEvent as OntimeEvent).title).toBe('A song from the hearth');
|
||||
});
|
||||
|
||||
it('imports groups', () => {
|
||||
it('imports group', () => {
|
||||
const testdata = [
|
||||
['Title', 'Timer type', 'duration'],
|
||||
['a group', 'group', '10m'],
|
||||
['an event', 'clock', '1m'],
|
||||
];
|
||||
|
||||
const importMap = {
|
||||
title: 'title',
|
||||
timerType: 'timer type',
|
||||
duration: 'duration',
|
||||
} as ImportMap;
|
||||
|
||||
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||
const firstGroup = result.rundown.entries[result.rundown.order[0]];
|
||||
|
||||
expect(result.rundown.order.length).toBe(1);
|
||||
expect(result.rundown.flatOrder.length).toBe(2);
|
||||
expect((firstGroup as OntimeGroup).type).toBe(SupportedEntry.Group);
|
||||
expect((firstGroup as OntimeGroup).targetDuration).toBe(10 * MILLIS_PER_MINUTE);
|
||||
});
|
||||
|
||||
it('places event between groups inside the group', () => {
|
||||
const testdata = [
|
||||
['Title', 'Timer type'],
|
||||
['a group', 'group'],
|
||||
['an event', 'clock'],
|
||||
['an event', 'clock'],
|
||||
['an event', 'clock'],
|
||||
['a second group ', 'group'],
|
||||
['an event', 'clock'],
|
||||
['an event', 'clock'],
|
||||
];
|
||||
|
||||
const importMap = {
|
||||
@@ -168,10 +195,17 @@ describe('parseExcel()', () => {
|
||||
timerType: 'timer type',
|
||||
};
|
||||
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||
const firstEvent = result.rundown.entries[result.rundown.order[0]];
|
||||
const firstGroup = result.rundown.entries[result.rundown.order[0]] as OntimeGroup;
|
||||
const secondGroup = result.rundown.entries[result.rundown.order[1]] as OntimeGroup;
|
||||
|
||||
expect(result.rundown.order.length).toBe(2);
|
||||
expect((firstEvent as OntimeEvent).type).toBe(SupportedEntry.Group);
|
||||
expect(result.rundown.flatOrder.length).toBe(7);
|
||||
|
||||
expect(firstGroup.type).toBe(SupportedEntry.Group);
|
||||
expect(firstGroup.entries.length).toBe(3);
|
||||
|
||||
expect(secondGroup.type).toBe(SupportedEntry.Group);
|
||||
expect(secondGroup.entries.length).toBe(2);
|
||||
});
|
||||
|
||||
it('imports as events if there is no timer type column', () => {
|
||||
@@ -314,8 +348,10 @@ describe('parseExcel()', () => {
|
||||
};
|
||||
|
||||
const result = parseExcel(testData, {}, 'testSheet', importMap);
|
||||
expect(result.rundown.order.length).toBe(6);
|
||||
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
|
||||
expect(result.rundown.order.length).toBe(5);
|
||||
expect(result.rundown.order).toMatchObject(['A', 'B', 'C', 'D', 'GROUP']);
|
||||
expect(result.rundown.flatOrder.length).toBe(6);
|
||||
expect(result.rundown.flatOrder).toMatchObject(['A', 'B', 'C', 'D', 'GROUP', 'E']);
|
||||
|
||||
expect(result.rundown.entries).toMatchObject({
|
||||
A: {
|
||||
@@ -417,162 +453,41 @@ describe('parseExcel()', () => {
|
||||
linkStart: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomFieldData()', () => {
|
||||
it('generates a list of keys from the given import map', () => {
|
||||
it('handles milestones', () => {
|
||||
const testdata = [
|
||||
['Title', 'type', 'notes'],
|
||||
['event...', 'count-down', ''],
|
||||
['also event...', 'count-down', ''],
|
||||
['this i a milestone', 'milestone', 'milestone note'],
|
||||
];
|
||||
|
||||
const importMap = {
|
||||
worksheet: 'event schedule',
|
||||
timeStart: 'time start',
|
||||
linkStart: 'link start',
|
||||
timeEnd: 'time end',
|
||||
duration: 'duration',
|
||||
flag: 'flag',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
countToEnd: 'count to end',
|
||||
skip: 'skip',
|
||||
timerType: 'type',
|
||||
note: 'notes',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
timeWarning: 'warning time',
|
||||
timeDanger: 'danger time',
|
||||
custom: {
|
||||
lighting: 'lx',
|
||||
sound: 'sound',
|
||||
video: 'av',
|
||||
},
|
||||
entryId: 'id',
|
||||
} as ImportMap;
|
||||
|
||||
const result = getCustomFieldData(importMap, {});
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
lighting: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
const result = parseExcel(testdata, {}, 'testSheet', importMap);
|
||||
const firstEvent = result.rundown.entries[result.rundown.order[0]];
|
||||
const secondEvent = result.rundown.entries[result.rundown.order[1]];
|
||||
const milestone = result.rundown.entries[result.rundown.order[2]];
|
||||
|
||||
expect(result.rundown.order.length).toBe(3);
|
||||
expect(firstEvent).toMatchObject({
|
||||
type: SupportedEntry.Event,
|
||||
timerType: TimerType.CountDown,
|
||||
});
|
||||
|
||||
// it is an inverted record of <importKey, ontimeKey>
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'lighting',
|
||||
sound: 'sound',
|
||||
av: 'video',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps colour information from existing fields', () => {
|
||||
const importMap = {
|
||||
worksheet: 'event schedule',
|
||||
timeStart: 'time start',
|
||||
linkStart: 'link start',
|
||||
timeEnd: 'time end',
|
||||
duration: 'duration',
|
||||
flag: 'flag',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
countToEnd: 'count to end',
|
||||
skip: 'skip',
|
||||
note: 'notes',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
timeWarning: 'warning time',
|
||||
timeDanger: 'danger time',
|
||||
custom: {
|
||||
lighting: 'lx',
|
||||
sound: 'sound',
|
||||
video: 'av',
|
||||
'ontime key': 'excel label',
|
||||
},
|
||||
entryId: 'id',
|
||||
} as ImportMap;
|
||||
|
||||
const existingCustomFields: CustomFields = {
|
||||
lighting: { label: 'lighting', type: 'text', colour: 'red' },
|
||||
sound: { label: 'sound', type: 'text', colour: 'green' },
|
||||
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, existingCustomFields);
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
lighting: {
|
||||
type: 'text',
|
||||
colour: 'red',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'text',
|
||||
colour: 'green',
|
||||
label: 'sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
ontime_key: {
|
||||
type: 'text',
|
||||
colour: 'blue',
|
||||
label: 'ontime key',
|
||||
},
|
||||
expect(secondEvent).toMatchObject({
|
||||
type: SupportedEntry.Event,
|
||||
timerType: TimerType.CountDown,
|
||||
});
|
||||
|
||||
// it is an inverted record of <importKey, ontimeKey>
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'lighting',
|
||||
sound: 'sound',
|
||||
av: 'video',
|
||||
'excel label': 'ontime_key',
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercases the keys in the import map', () => {
|
||||
const importMap: ImportMap = {
|
||||
...defaultImportMap,
|
||||
custom: {
|
||||
Lighting: 'Lx',
|
||||
Sound: 'sound',
|
||||
video: 'av',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, {});
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
Lighting: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'Lighting',
|
||||
},
|
||||
Sound: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'Sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
});
|
||||
|
||||
// notice that the keys excel keys are lowercased
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'Lighting',
|
||||
sound: 'Sound',
|
||||
av: 'video',
|
||||
expect(milestone).toMatchObject({
|
||||
type: SupportedEntry.Milestone,
|
||||
title: 'this i a milestone',
|
||||
note: 'milestone note',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { CustomFields } from 'ontime-types';
|
||||
import { defaultImportMap, ImportMap } from 'ontime-utils';
|
||||
|
||||
import { getCustomFieldData } from '../excel.utils.js';
|
||||
|
||||
describe('getCustomFieldData()', () => {
|
||||
it('generates a list of keys from the given import map', () => {
|
||||
const importMap = {
|
||||
worksheet: 'event schedule',
|
||||
timeStart: 'time start',
|
||||
linkStart: 'link start',
|
||||
timeEnd: 'time end',
|
||||
duration: 'duration',
|
||||
flag: 'flag',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
countToEnd: 'count to end',
|
||||
skip: 'skip',
|
||||
note: 'notes',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
timeWarning: 'warning time',
|
||||
timeDanger: 'danger time',
|
||||
custom: {
|
||||
lighting: 'lx',
|
||||
sound: 'sound',
|
||||
video: 'av',
|
||||
},
|
||||
entryId: 'id',
|
||||
} as ImportMap;
|
||||
|
||||
const result = getCustomFieldData(importMap, {});
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
lighting: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
});
|
||||
|
||||
// it is an inverted record of <importKey, ontimeKey>
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'lighting',
|
||||
sound: 'sound',
|
||||
av: 'video',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps colour information from existing fields', () => {
|
||||
const importMap = {
|
||||
worksheet: 'event schedule',
|
||||
timeStart: 'time start',
|
||||
linkStart: 'link start',
|
||||
timeEnd: 'time end',
|
||||
duration: 'duration',
|
||||
flag: 'flag',
|
||||
cue: 'cue',
|
||||
title: 'title',
|
||||
countToEnd: 'count to end',
|
||||
skip: 'skip',
|
||||
note: 'notes',
|
||||
colour: 'colour',
|
||||
endAction: 'end action',
|
||||
timerType: 'timer type',
|
||||
timeWarning: 'warning time',
|
||||
timeDanger: 'danger time',
|
||||
custom: {
|
||||
lighting: 'lx',
|
||||
sound: 'sound',
|
||||
video: 'av',
|
||||
'ontime key': 'excel label',
|
||||
},
|
||||
entryId: 'id',
|
||||
} as ImportMap;
|
||||
|
||||
const existingCustomFields: CustomFields = {
|
||||
lighting: { label: 'lighting', type: 'text', colour: 'red' },
|
||||
sound: { label: 'sound', type: 'text', colour: 'green' },
|
||||
ontime_key: { label: 'ontime key', type: 'text', colour: 'blue' },
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, existingCustomFields);
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
lighting: {
|
||||
type: 'text',
|
||||
colour: 'red',
|
||||
label: 'lighting',
|
||||
},
|
||||
sound: {
|
||||
type: 'text',
|
||||
colour: 'green',
|
||||
label: 'sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
ontime_key: {
|
||||
type: 'text',
|
||||
colour: 'blue',
|
||||
label: 'ontime key',
|
||||
},
|
||||
});
|
||||
|
||||
// it is an inverted record of <importKey, ontimeKey>
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'lighting',
|
||||
sound: 'sound',
|
||||
av: 'video',
|
||||
'excel label': 'ontime_key',
|
||||
});
|
||||
});
|
||||
|
||||
it('lowercases the keys in the import map', () => {
|
||||
const importMap: ImportMap = {
|
||||
...defaultImportMap,
|
||||
custom: {
|
||||
Lighting: 'Lx',
|
||||
Sound: 'sound',
|
||||
video: 'av',
|
||||
},
|
||||
};
|
||||
|
||||
const result = getCustomFieldData(importMap, {});
|
||||
expect(result.mergedCustomFields).toStrictEqual({
|
||||
Lighting: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'Lighting',
|
||||
},
|
||||
Sound: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'Sound',
|
||||
},
|
||||
video: {
|
||||
type: 'text',
|
||||
colour: '',
|
||||
label: 'video',
|
||||
},
|
||||
});
|
||||
|
||||
// notice that the keys excel keys are lowercased
|
||||
expect(result.customFieldImportKeys).toStrictEqual({
|
||||
lx: 'Lighting',
|
||||
sound: 'Sound',
|
||||
av: 'video',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
SupportedEntry,
|
||||
isOntimeGroup,
|
||||
TimerType,
|
||||
CustomFieldKey,
|
||||
OntimeMilestone,
|
||||
OntimeEntry,
|
||||
isOntimeMilestone,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
ImportMap,
|
||||
@@ -16,22 +18,26 @@ import {
|
||||
isKnownTimerType,
|
||||
validateTimerType,
|
||||
validateEndAction,
|
||||
customFieldLabelToKey,
|
||||
checkRegex,
|
||||
} from 'ontime-utils';
|
||||
|
||||
import { Merge } from 'ts-essentials';
|
||||
import { Prettify } from 'ts-essentials';
|
||||
|
||||
import { is } from '../../utils/is.js';
|
||||
import { makeString } from '../../utils/parserUtils.js';
|
||||
import { parseExcelDate } from '../../utils/time.js';
|
||||
import { generateImportHandlers, getCustomFieldData, parseBooleanString, SheetMetadata } from './excel.utils.js';
|
||||
|
||||
type MergedOntimeEntry = Prettify<
|
||||
Omit<Omit<Omit<OntimeEvent, keyof OntimeGroup> & OntimeGroup, keyof OntimeMilestone> & OntimeMilestone, 'type'> & {
|
||||
type: SupportedEntry | 'group-end';
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* @description Excel array parser
|
||||
* @param {array} excelData - array with excel sheet
|
||||
* @param {ImportOptions} options - an object that contains the import map
|
||||
* @returns {object} - parsed object
|
||||
* TODO: import milestones
|
||||
*/
|
||||
export const parseExcel = (
|
||||
excelData: unknown[][],
|
||||
@@ -41,9 +47,8 @@ export const parseExcel = (
|
||||
): {
|
||||
rundown: Rundown;
|
||||
customFields: CustomFields;
|
||||
rundownMetadata: Record<string, { row: number; col: number }>;
|
||||
sheetMetadata: SheetMetadata;
|
||||
} => {
|
||||
const rundownMetadata: Record<string, { row: number; col: number }> = {};
|
||||
const importMap: ImportMap = { ...defaultImportMap, ...options };
|
||||
|
||||
for (const [key, value] of Object.entries(importMap)) {
|
||||
@@ -63,166 +68,74 @@ export const parseExcel = (
|
||||
revision: 0,
|
||||
};
|
||||
|
||||
// title stuff: strings
|
||||
let titleIndex: number | null = null;
|
||||
let cueIndex: number | null = null;
|
||||
let notesIndex: number | null = null;
|
||||
let colourIndex: number | null = null;
|
||||
|
||||
// options: booleans
|
||||
let flagIndex: number | null = null;
|
||||
let skipIndex: number | null = null;
|
||||
let countToEndIndex: number | null = null;
|
||||
|
||||
let linkStartIndex: number | null = null;
|
||||
|
||||
// times: numbers
|
||||
let timeStartIndex: number | null = null;
|
||||
let timeEndIndex: number | null = null;
|
||||
let durationIndex: number | null = null;
|
||||
let timeWarningIndex: number | null = null;
|
||||
let timeDangerIndex: number | null = null;
|
||||
|
||||
// options: enum properties
|
||||
let endActionIndex: number | null = null;
|
||||
let timerTypeIndex: number | null = null;
|
||||
|
||||
//ID
|
||||
let entryIdIndex: number | null = null;
|
||||
|
||||
// record of column index and the name of the field
|
||||
const customFieldIndexes: Record<number, string> = {};
|
||||
// for placing entries into groups
|
||||
let currentGroupId: string | null = null;
|
||||
const groupEntries: string[] = [];
|
||||
const { handlers, indexMap, sheetMetadata } = generateImportHandlers(importMap);
|
||||
|
||||
excelData.forEach((row, rowIndex) => {
|
||||
if (row.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: extract generating handlers from importMap
|
||||
const handlers = {
|
||||
[importMap.timeStart]: (row: number, col: number) => {
|
||||
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 };
|
||||
},
|
||||
[importMap.duration]: (row: number, col: number) => {
|
||||
durationIndex = col;
|
||||
rundownMetadata['duration'] = { row, col };
|
||||
},
|
||||
const entry: Partial<MergedOntimeEntry> = {};
|
||||
|
||||
[importMap.cue]: (row: number, col: number) => {
|
||||
cueIndex = col;
|
||||
rundownMetadata['cue'] = { row, col };
|
||||
},
|
||||
[importMap.title]: (row: number, col: number) => {
|
||||
titleIndex = col;
|
||||
rundownMetadata['title'] = { row, col };
|
||||
},
|
||||
[importMap.flag]: (row: number, col: number) => {
|
||||
flagIndex = col;
|
||||
rundownMetadata['flag'] = { row, col };
|
||||
},
|
||||
[importMap.countToEnd]: (row: number, col: number) => {
|
||||
countToEndIndex = col;
|
||||
rundownMetadata['countToEnd'] = { row, col };
|
||||
},
|
||||
[importMap.skip]: (row: number, col: number) => {
|
||||
skipIndex = col;
|
||||
rundownMetadata['skip'] = { row, col };
|
||||
},
|
||||
[importMap.note]: (row: number, col: number) => {
|
||||
notesIndex = col;
|
||||
rundownMetadata['note'] = { row, col };
|
||||
},
|
||||
[importMap.colour]: (row: number, col: number) => {
|
||||
colourIndex = col;
|
||||
rundownMetadata['colour'] = { row, col };
|
||||
},
|
||||
[importMap.endAction]: (row: number, col: number) => {
|
||||
endActionIndex = col;
|
||||
rundownMetadata['endAction'] = { row, col };
|
||||
},
|
||||
[importMap.timerType]: (row: number, col: number) => {
|
||||
timerTypeIndex = col;
|
||||
rundownMetadata['timerType'] = { row, col };
|
||||
},
|
||||
[importMap.timeWarning]: (row: number, col: number) => {
|
||||
timeWarningIndex = col;
|
||||
rundownMetadata['timeWarning'] = { row, col };
|
||||
},
|
||||
[importMap.timeDanger]: (row: number, col: number) => {
|
||||
timeDangerIndex = col;
|
||||
rundownMetadata['timeDanger'] = { row, col };
|
||||
},
|
||||
[importMap.entryId]: (row: number, col: number) => {
|
||||
entryIdIndex = col;
|
||||
rundownMetadata['id'] = { row, col };
|
||||
},
|
||||
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||
customFieldIndexes[col] = columnText;
|
||||
rundownMetadata[`custom:${ontimeKey}`] = { row, col };
|
||||
},
|
||||
} as const;
|
||||
|
||||
const entry: Partial<Merge<OntimeEvent, OntimeGroup>> = {};
|
||||
const entryCustomFields: EntryCustomFields = {};
|
||||
|
||||
for (let j = 0; j < row.length; j++) {
|
||||
const column = row[j];
|
||||
// 1. we check if we have set a flag for a known field
|
||||
if (j === timerTypeIndex) {
|
||||
const maybeTimeType = makeString(column, '');
|
||||
if (maybeTimeType === 'group') {
|
||||
// we leave this as a clue for the object filtering later on
|
||||
if (j === indexMap.timerType) {
|
||||
const maybeTimeType = makeString(column, '').toLowerCase();
|
||||
if (maybeTimeType === 'group' || maybeTimeType === 'group-start') {
|
||||
entry.type = SupportedEntry.Group;
|
||||
entry.entries = [];
|
||||
} else if (maybeTimeType === 'group-end') {
|
||||
entry.type = 'group-end';
|
||||
} else if (maybeTimeType === 'milestone') {
|
||||
entry.type = SupportedEntry.Milestone;
|
||||
} else if (maybeTimeType === 'skip-import') {
|
||||
// intentional skip
|
||||
return;
|
||||
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
|
||||
// @ts-expect-error -- we leave this as a clue for the object filtering later on
|
||||
entry.type = SupportedEntry.Event;
|
||||
entry.timerType = validateTimerType(maybeTimeType);
|
||||
} else {
|
||||
// if it is not a group or a known type, we dont import it
|
||||
return;
|
||||
}
|
||||
} else if (j === titleIndex) {
|
||||
} else if (j === indexMap.title) {
|
||||
entry.title = makeString(column, '');
|
||||
} else if (j === timeStartIndex) {
|
||||
} else if (j === indexMap.timeStart) {
|
||||
entry.timeStart = parseExcelDate(column);
|
||||
} else if (j === linkStartIndex) {
|
||||
} else if (j === indexMap.linkStart) {
|
||||
entry.linkStart = parseBooleanString(column);
|
||||
} else if (j === timeEndIndex) {
|
||||
} else if (j === indexMap.timeEnd) {
|
||||
entry.timeEnd = parseExcelDate(column);
|
||||
} else if (j === durationIndex) {
|
||||
} else if (j === indexMap.duration) {
|
||||
entry.duration = parseExcelDate(column);
|
||||
} else if (j === cueIndex) {
|
||||
} else if (j === indexMap.cue) {
|
||||
entry.cue = makeString(column, '');
|
||||
} else if (j === flagIndex) {
|
||||
} else if (j === indexMap.flag) {
|
||||
entry.flag = parseBooleanString(column);
|
||||
} else if (j === countToEndIndex) {
|
||||
} else if (j === indexMap.countToEnd) {
|
||||
entry.countToEnd = parseBooleanString(column);
|
||||
} else if (j === skipIndex) {
|
||||
} else if (j === indexMap.skip) {
|
||||
entry.skip = parseBooleanString(column);
|
||||
} else if (j === notesIndex) {
|
||||
} else if (j === indexMap.note) {
|
||||
entry.note = makeString(column, '');
|
||||
} else if (j === endActionIndex) {
|
||||
} else if (j === indexMap.endAction) {
|
||||
entry.endAction = validateEndAction(column);
|
||||
} else if (j === timeWarningIndex) {
|
||||
} else if (j === indexMap.timeWarning) {
|
||||
entry.timeWarning = parseExcelDate(column);
|
||||
} else if (j === timeDangerIndex) {
|
||||
} else if (j === indexMap.timeDanger) {
|
||||
entry.timeDanger = parseExcelDate(column);
|
||||
} else if (j === colourIndex) {
|
||||
} else if (j === indexMap.colour) {
|
||||
entry.colour = makeString(column, '');
|
||||
} else if (j === entryIdIndex) {
|
||||
} else if (j === indexMap.entryId) {
|
||||
entry.id = encodeURIComponent(makeString(column, undefined));
|
||||
} else if (j in customFieldIndexes) {
|
||||
const importKey = customFieldIndexes[j];
|
||||
} else if (j in indexMap.custom) {
|
||||
const importKey = indexMap.custom[j];
|
||||
const ontimeKey = customFieldImportKeys[importKey];
|
||||
entryCustomFields[ontimeKey] = makeString(column, '');
|
||||
} else {
|
||||
@@ -259,92 +172,78 @@ export const parseExcel = (
|
||||
}
|
||||
|
||||
const id = entry.id || generateId();
|
||||
// from excel, we can only get groups, milestones and events
|
||||
if (isOntimeGroup(entry)) {
|
||||
const group: OntimeGroup = { ...entry, custom: { ...entryCustomFields } };
|
||||
rundown.order.push(id);
|
||||
rundown.entries[id] = group;
|
||||
|
||||
if (entry.type === 'group-end') {
|
||||
if (currentGroupId) {
|
||||
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||
currentGroupId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// from excel, we can only get groups, milestones and events
|
||||
if (isOntimeGroup(entry as OntimeEntry)) {
|
||||
const group = {
|
||||
...entry,
|
||||
targetDuration: entry.duration ? entry.duration : null,
|
||||
custom: { ...entryCustomFields },
|
||||
} as OntimeGroup;
|
||||
|
||||
rundown.entries[id] = group;
|
||||
if (currentGroupId) {
|
||||
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||
}
|
||||
rundown.order.push(id);
|
||||
rundown.flatOrder.push(id);
|
||||
currentGroupId = id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOntimeMilestone(entry as OntimeEntry)) {
|
||||
const milestone = { ...entry, custom: { ...entryCustomFields } } as OntimeMilestone;
|
||||
if (currentGroupId) {
|
||||
groupEntries.push(id);
|
||||
milestone.parent = currentGroupId;
|
||||
} else {
|
||||
rundown.order.push(id);
|
||||
}
|
||||
rundown.flatOrder.push(id);
|
||||
rundown.entries[id] = milestone;
|
||||
return;
|
||||
}
|
||||
|
||||
//and fall through to treat it as an event
|
||||
const event = {
|
||||
...entry,
|
||||
custom: { ...entryCustomFields },
|
||||
type: SupportedEntry.Event,
|
||||
} as OntimeEvent;
|
||||
|
||||
if (timerTypeIndex === null) {
|
||||
if (indexMap.timerType === null) {
|
||||
event.timerType = TimerType.CountDown;
|
||||
}
|
||||
rundown.order.push(id);
|
||||
|
||||
if (entry.linkStart === undefined) {
|
||||
event.linkStart = true;
|
||||
}
|
||||
|
||||
if (currentGroupId) {
|
||||
groupEntries.push(id);
|
||||
event.parent = currentGroupId;
|
||||
} else {
|
||||
rundown.order.push(id);
|
||||
}
|
||||
rundown.flatOrder.push(id);
|
||||
rundown.entries[id] = event;
|
||||
});
|
||||
|
||||
if (currentGroupId) {
|
||||
(rundown.entries[currentGroupId] as OntimeGroup).entries = groupEntries.splice(0);
|
||||
}
|
||||
|
||||
return {
|
||||
rundown,
|
||||
customFields: mergedCustomFields,
|
||||
rundownMetadata,
|
||||
sheetMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function infers a boolean from a string value
|
||||
*/
|
||||
function parseBooleanString(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// falsy values would be nullish or empty string
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return value.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an import map which contains custom field labels and a custom fields object
|
||||
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
||||
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
|
||||
* @returns the new custom fields, and a map of excel column names to ontime keys
|
||||
* @private exported for testing
|
||||
*/
|
||||
export function getCustomFieldData(
|
||||
importMap: ImportMap,
|
||||
existingCustomFields: CustomFields,
|
||||
): {
|
||||
mergedCustomFields: CustomFields;
|
||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
||||
} {
|
||||
const mergedCustomFields: CustomFields = {};
|
||||
/**
|
||||
* A map of import keys to ontime keys
|
||||
* Map<excel column name, ontime key>
|
||||
*/
|
||||
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
|
||||
|
||||
for (const ontimeLabel in importMap.custom) {
|
||||
// if the label is not valid, we skip the import
|
||||
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// generate a key for the custom field
|
||||
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
|
||||
// we lower case the excel key to make it easier to match
|
||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||
|
||||
// 1. add the custom field to the merged custom fields
|
||||
mergedCustomFields[keyInCustomFields] = {
|
||||
type: 'text', // we currently only support text custom fields
|
||||
colour: maybeExistingColour,
|
||||
label: ontimeLabel,
|
||||
};
|
||||
|
||||
// 2. add the column to the import keys
|
||||
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
||||
}
|
||||
return { mergedCustomFields, customFieldImportKeys };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { CustomFieldKey, CustomFields, MaybeNumber } from 'ontime-types';
|
||||
import { checkRegex, customFieldLabelToKey, ImportMap } from 'ontime-utils';
|
||||
|
||||
/**
|
||||
* Receives an import map which contains custom field labels and a custom fields object
|
||||
* the result importkeys is an inverted record of <importKey, ontimeKey>
|
||||
* We need this function since, when importing from sheets, the user gives us custom field labels, not keys
|
||||
* @returns the new custom fields, and a map of excel column names to ontime keys
|
||||
* @private exported for testing
|
||||
*/
|
||||
export function getCustomFieldData(
|
||||
importMap: ImportMap,
|
||||
existingCustomFields: CustomFields,
|
||||
): {
|
||||
mergedCustomFields: CustomFields;
|
||||
customFieldImportKeys: Record<keyof CustomFields, string>;
|
||||
} {
|
||||
const mergedCustomFields: CustomFields = {};
|
||||
/**
|
||||
* A map of import keys to ontime keys
|
||||
* Map<excel column name, ontime key>
|
||||
*/
|
||||
const customFieldImportKeys: Record<string, CustomFieldKey> = {};
|
||||
|
||||
for (const ontimeLabel in importMap.custom) {
|
||||
// if the label is not valid, we skip the import
|
||||
if (!checkRegex.isAlphanumericWithSpace(ontimeLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// generate a key for the custom field
|
||||
const keyInCustomFields = customFieldLabelToKey(ontimeLabel);
|
||||
// we lower case the excel key to make it easier to match
|
||||
const columnNameInExcel = importMap.custom[ontimeLabel].toLowerCase();
|
||||
const maybeExistingColour = existingCustomFields[keyInCustomFields]?.colour ?? '';
|
||||
|
||||
// 1. add the custom field to the merged custom fields
|
||||
mergedCustomFields[keyInCustomFields] = {
|
||||
type: 'text', // we currently only support text custom fields
|
||||
colour: maybeExistingColour,
|
||||
label: ontimeLabel,
|
||||
};
|
||||
|
||||
// 2. add the column to the import keys
|
||||
customFieldImportKeys[columnNameInExcel] = keyInCustomFields;
|
||||
}
|
||||
return { mergedCustomFields, customFieldImportKeys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function infers a boolean from a string value
|
||||
*/
|
||||
export function parseBooleanString(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// falsy values would be nullish or empty string
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return value.toLowerCase() !== 'false';
|
||||
}
|
||||
|
||||
type IndexMap = Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, MaybeNumber> &
|
||||
Record<keyof Pick<ImportMap, 'custom'>, Record<number, string>>;
|
||||
|
||||
export type SheetMetadata = Partial<
|
||||
Record<keyof Omit<ImportMap, 'worksheet' | 'custom'>, { row: number; col: number }> &
|
||||
Record<string, { row: number; col: number }>
|
||||
>;
|
||||
|
||||
export function generateImportHandlers(importMap: ImportMap) {
|
||||
const indexMap: IndexMap = {
|
||||
title: null,
|
||||
cue: null,
|
||||
note: null,
|
||||
colour: null,
|
||||
flag: null,
|
||||
skip: null,
|
||||
countToEnd: null,
|
||||
linkStart: null,
|
||||
timeStart: null,
|
||||
timeEnd: null,
|
||||
duration: null,
|
||||
timeWarning: null,
|
||||
timeDanger: null,
|
||||
endAction: null,
|
||||
timerType: null,
|
||||
entryId: null,
|
||||
custom: {},
|
||||
};
|
||||
|
||||
const sheetMetadata: SheetMetadata = {};
|
||||
|
||||
const handlers = {
|
||||
[importMap.timeStart]: (row: number, col: number) => {
|
||||
indexMap.timeStart = col;
|
||||
sheetMetadata.timeStart = { row, col };
|
||||
},
|
||||
[importMap.linkStart]: (row: number, col: number) => {
|
||||
indexMap.linkStart = col;
|
||||
sheetMetadata.linkStart = { row, col };
|
||||
},
|
||||
[importMap.timeEnd]: (row: number, col: number) => {
|
||||
indexMap.timeEnd = col;
|
||||
sheetMetadata.timeEnd = { row, col };
|
||||
},
|
||||
[importMap.duration]: (row: number, col: number) => {
|
||||
indexMap.duration = col;
|
||||
sheetMetadata.duration = { row, col };
|
||||
},
|
||||
|
||||
[importMap.cue]: (row: number, col: number) => {
|
||||
indexMap.cue = col;
|
||||
sheetMetadata.cue = { row, col };
|
||||
},
|
||||
[importMap.title]: (row: number, col: number) => {
|
||||
indexMap.title = col;
|
||||
sheetMetadata.title = { row, col };
|
||||
},
|
||||
[importMap.flag]: (row: number, col: number) => {
|
||||
indexMap.flag = col;
|
||||
sheetMetadata.flag = { row, col };
|
||||
},
|
||||
[importMap.countToEnd]: (row: number, col: number) => {
|
||||
indexMap.countToEnd = col;
|
||||
sheetMetadata.countToEnd = { row, col };
|
||||
},
|
||||
[importMap.skip]: (row: number, col: number) => {
|
||||
indexMap.skip = col;
|
||||
sheetMetadata.skip = { row, col };
|
||||
},
|
||||
[importMap.note]: (row: number, col: number) => {
|
||||
indexMap.note = col;
|
||||
sheetMetadata.note = { row, col };
|
||||
},
|
||||
[importMap.colour]: (row: number, col: number) => {
|
||||
indexMap.colour = col;
|
||||
sheetMetadata.colour = { row, col };
|
||||
},
|
||||
[importMap.endAction]: (row: number, col: number) => {
|
||||
indexMap.endAction = col;
|
||||
sheetMetadata.endAction = { row, col };
|
||||
},
|
||||
[importMap.timerType]: (row: number, col: number) => {
|
||||
indexMap.timerType = col;
|
||||
sheetMetadata.timerType = { row, col };
|
||||
},
|
||||
[importMap.timeWarning]: (row: number, col: number) => {
|
||||
indexMap.timeWarning = col;
|
||||
sheetMetadata.timeWarning = { row, col };
|
||||
},
|
||||
[importMap.timeDanger]: (row: number, col: number) => {
|
||||
indexMap.timeDanger = col;
|
||||
sheetMetadata.timeDanger = { row, col };
|
||||
},
|
||||
[importMap.entryId]: (row: number, col: number) => {
|
||||
indexMap.entryId = col;
|
||||
sheetMetadata['id'] = { row, col }; // important this will be used in a normal context where the id is not called entryId
|
||||
},
|
||||
custom: (row: number, col: number, columnText: string, ontimeKey: string) => {
|
||||
indexMap.custom[col] = columnText;
|
||||
sheetMetadata[`custom:${ontimeKey}`] = { row, col };
|
||||
},
|
||||
};
|
||||
|
||||
return { handlers, indexMap, sheetMetadata };
|
||||
}
|
||||
Reference in New Issue
Block a user