mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
1d8c04d0b4
refactor: align id columns between export and import refactor: match column names with template
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
import { CustomFieldKey, CustomFields, MaybeNumber, OntimeEntry, Rundown } from 'ontime-types';
|
|
import { checkRegex, customFieldLabelToKey, ImportMap, millisToString } 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,
|
|
id: 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.id]: (row: number, col: number) => {
|
|
indexMap.id = 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 };
|
|
}
|
|
|
|
const emptyCellValue = '';
|
|
|
|
/**
|
|
* Converts a given rundown to 2D array tabular format
|
|
* All fields are converted to their string representation as specified by excel
|
|
*/
|
|
export function rundownToTabular(rundown: Rundown, customFields: CustomFields): string[][] {
|
|
const flatRundown: string[][] = [];
|
|
// we reuse the keys from the import map so import <> export works by default
|
|
const keys = Object.keys(matchedWithDefaultImportMap) as (keyof typeof matchedWithDefaultImportMap)[];
|
|
const customFieldKeys = Object.keys(customFields) as CustomFieldKey[];
|
|
|
|
// create header row
|
|
const headerRow: string[] = [];
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
headerRow.push(matchedWithDefaultImportMap[key]);
|
|
}
|
|
for (let i = 0; i < customFieldKeys.length; i++) {
|
|
const key = customFieldKeys[i];
|
|
headerRow.push(customFields[key].label);
|
|
}
|
|
flatRundown.push(headerRow);
|
|
|
|
// create table body
|
|
let previousEntry: OntimeEntry | undefined;
|
|
for (let i = 0; i < rundown.flatOrder.length; i++) {
|
|
const entryId = rundown.flatOrder[i];
|
|
const entry = rundown.entries[entryId];
|
|
if (!entry) continue;
|
|
// we opt for not exporting delays as they are only relevant for runtime
|
|
if (entry.type === 'delay') continue;
|
|
|
|
// we need a group-end if:
|
|
// - the previous entry was a group and now we have another group (both parents are undefined)
|
|
// - the previous entry had a parent and the new entry is a group (parent undefined)
|
|
// - the previous entry had a parent and the new one doesnt (parent null)
|
|
if (
|
|
previousEntry &&
|
|
((previousEntry.type === 'group' && entry.type === 'group') ||
|
|
// @ts-expect-error -- we safely check if the property is nullish
|
|
(previousEntry?.parent !== null && entry?.parent == null))
|
|
) {
|
|
addGroupEnd();
|
|
}
|
|
|
|
previousEntry = entry;
|
|
const entryRow: string[] = [];
|
|
|
|
// each entry is a row in the table
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
|
|
// handle edge cases for group
|
|
if (entry.type === 'group') {
|
|
if (key === 'duration') {
|
|
const groupDuration = entry.targetDuration === null ? '' : parseField('duration', entry.targetDuration);
|
|
entryRow.push(groupDuration);
|
|
continue;
|
|
} else if (key === 'timerType') {
|
|
entryRow.push('group');
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// handle edge cases for milestones
|
|
if (entry.type === 'milestone') {
|
|
if (key === 'timerType') {
|
|
entryRow.push('milestone');
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// handle edge cases for events
|
|
if (entry.type === 'event') {
|
|
if (key === 'endAction' && entry.endAction === 'none') {
|
|
entryRow.push(emptyCellValue);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (key in entry) {
|
|
entryRow.push(parseField(key, entry[key as keyof typeof entry]));
|
|
} else {
|
|
// we push an empty cell to keep the column positioning
|
|
entryRow.push(emptyCellValue);
|
|
}
|
|
}
|
|
|
|
// add custom fields
|
|
if ('custom' in entry) {
|
|
for (let i = 0; i < customFieldKeys.length; i++) {
|
|
const customFieldKey = customFieldKeys[i] as CustomFieldKey;
|
|
const value = entry.custom?.[customFieldKey] ?? emptyCellValue;
|
|
entryRow.push(String(value));
|
|
}
|
|
}
|
|
flatRundown.push(entryRow);
|
|
}
|
|
|
|
// close dangling groups
|
|
// - last element as a group: previous entry.parent is undefined
|
|
// - last element was in a group: previous entry.parent is string
|
|
if (previousEntry && (previousEntry.type === 'group' || previousEntry.parent !== null)) {
|
|
addGroupEnd();
|
|
}
|
|
|
|
/**
|
|
* Adds a new row with group-end timerType
|
|
* Ensures that all columns are filled with the correct type
|
|
*/
|
|
function addGroupEnd() {
|
|
const emptyRow = Array(keys.length).fill(emptyCellValue);
|
|
const timerTypeIndex = keys.indexOf('timerType');
|
|
emptyRow[timerTypeIndex] = 'group-end';
|
|
flatRundown.push(emptyRow);
|
|
}
|
|
|
|
return flatRundown;
|
|
}
|
|
|
|
/**
|
|
* Map between the ontime key and the excel column name
|
|
* This needs to match match the default import map
|
|
* TODO: could we organise these objects to have a single source of truth?
|
|
*/
|
|
const matchedWithDefaultImportMap = {
|
|
id: 'ID',
|
|
flag: 'Flag',
|
|
cue: 'Cue',
|
|
title: 'Title',
|
|
colour: 'Colour',
|
|
timeStart: 'Time Start',
|
|
timeEnd: 'Time End',
|
|
duration: 'Duration',
|
|
linkStart: 'Link Start',
|
|
countToEnd: 'Count to end',
|
|
note: 'Note',
|
|
timerType: 'Timer Type',
|
|
endAction: 'End Action',
|
|
timeWarning: 'Warning Time',
|
|
timeDanger: 'Danger Time',
|
|
skip: 'Skip',
|
|
};
|
|
|
|
/**
|
|
* Parses a fields value into a string suitable for excel
|
|
*/
|
|
function parseField(field: string, data: unknown) {
|
|
if (data == null) return emptyCellValue;
|
|
|
|
if (
|
|
field === 'timeStart' ||
|
|
field === 'timeEnd' ||
|
|
field === 'duration' ||
|
|
field === 'timeWarning' ||
|
|
field === 'timeDanger'
|
|
) {
|
|
return millisToString(data as MaybeNumber, { fallback: emptyCellValue });
|
|
}
|
|
|
|
if (typeof data === 'boolean') {
|
|
return data ? 'TRUE' : emptyCellValue;
|
|
}
|
|
|
|
return typeof data == 'string' ? data : String(data);
|
|
}
|