mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-04 06:58:02 +00:00
722e045b20
* Revert "refactor: add version to session endpoint" This reverts commitcd40a6e55e. * Revert "Remove public event feature (#1645)" This reverts commit08d9e24871. * Revert "Fix: rearrange playing event (#1640)" This reverts commit0649678dca. * Revert "refactor: style tweaks to edit css modal" This reverts commita00ec2d02a. * Revert "refactor: remove usages of framer-motion" This reverts commit54a74ccc2a. * Revert "Upgrade expressjs (#1633)" This reverts commit6f3ab274bd. * Revert "Refactor: require trigger in all events objects (#1636)" This reverts commit90870ecfb6. * Revert "Fix: Correct boundary condition in applyDelay" This reverts commitb640e0e181. * Revert "let vite be the proxy to the dev server (#1630)" This reverts commitc41fe824cf. * Revert "refactor: migrate custom fields to transactions" This reverts commit62c8319d70. * Revert "refactor: simplify validations" This reverts commitb1d23467a2. * Revert "refactor: create transaction system and apply to adding entry (#1620)" This reverts commit9a62daf047. * Revert "Refactor: better rounding (#1594)" This reverts commitb9ab1c6fd7. * Revert "refactor: improve reorder logic" This reverts commitc1054711b0. * Revert "chore: simplify URLs" This reverts commita4d4f29a37. * Revert "refactor: order is single source of truth" This reverts commit2793aadea0. * Revert "refactor: refetch targets is enum" This reverts commite7cfb7d9d9. * Revert "refactor: small ux improvements" This reverts commit256a851c9b. * Revert "refactor: remove trivially inferred numEvents" This reverts commit4ab9c81cb8. * Revert "feat: duplicate groups" This reverts commit2f13d6c89e. * Revert "feat: create group from entry selection" This reverts commitf1f7bad25e. * Revert "feat: create block from rundown empty" This reverts commita8b52a48f7. * Revert "fix: collapsed blocks dont render children" This reverts commitcd0999b2ab. * Revert "refactor: type cleanup and test improvements" This reverts commitb4c60f3f04. * Revert "feat: allow dissolving a block" This reverts commit5cefad3666. * Revert "fix: uncontrolled prop on controlled component" This reverts commit7a6ecd8c34. * Revert "refactor: improve return of reorder" This reverts commitba96ecfd91. * Revert "refactor: mutations on batch elements must have IDs" This reverts commit7bed3757f2. * Revert "chore: upgrade dependencies" This reverts commite2e755b1d2. * Revert "refactor: extract utility to merge two arrays" This reverts commita77d23109d. * Revert "refactor: change network mode defaults" This reverts commit0eb3b8d382. * Revert "fix: delete nested events" This reverts commit0021185288. * Revert "fix: add event at end of block" This reverts commit94c72ff4f6. * Revert "refactor: make finder available in exported rundown" This reverts commite4c08dc9b2. * Revert "assert non null and update test (#1604)" This reverts commitec74af0d62. * Revert "Fix project renumber (#1597)" This reverts commitb6d72dd082. * Revert "Refactor: WebSocket from flush queue to one patch (#1595)" This reverts commit31c311daf0. * Revert "Refactor: ms for api calls (#1593)" This reverts commite9b3cc6090. * Revert "fix test (#1601)" This reverts commit543b04a097. * Revert "fix: rebase master" This reverts commitd39b85b6e6. * Revert "chore: correct test path" This reverts commitbbe107bb2b. * Revert "refactor: extract rundown parsing" This reverts commitc616240db1. * Revert "chore: improve convention entry <> event" This reverts commit166be66ce3. * Revert "refactor: maintain flat orders" This reverts commit4180d0a337. * Revert "refactor: implement operations on nested events" This reverts commit78108e316c. * Revert "refactor: process events in rundown" This reverts commit3bb8b70915. * Revert "chore: improve convention entry <> event" This reverts commit1c4f13a0ed. * Revert "chore: rename currentBlock > parent" This reverts commit3ca0abad53. * Revert "refactor: fix delay positioning in gaps" This reverts commit030c8f897f. * Revert "refactor(e2e): skip flaky test" This reverts commit68175cfa3b. * Revert "refactor: improve project loading" This reverts commitfd8f757851. * Revert "refactor: gather group metadata" This reverts commit876d111c61. * Revert "refactor: swap maintains schedule" This reverts commit730cb95c04. * Revert "chore: rename files" This reverts commit3c388d4fb5. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit2e23718d73. * Revert "refactor: clearer relationship on rundown elements" This reverts commit89ea8c470b. * Revert "refactor: use strict typing" This reverts commit178640bfc4. * Revert "refactor: remove stop as a possible end action" This reverts commit4ed38340e0. * Revert "refactor: restructure model to contain an object of rundowns" This reverts commit69eb9a5eff. * Revert "chore: remove IDE files" This reverts commit351425127a. * Revert "refactor: remove unused and legacy code" This reverts commit95f2ba37cc.
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import {
|
|
customFieldLabelToKey,
|
|
customKeyFromLabel,
|
|
defaultImportMap,
|
|
generateId,
|
|
type ImportMap,
|
|
isKnownTimerType,
|
|
validateEndAction,
|
|
validateLinkStart,
|
|
validateTimerType,
|
|
validateTimes,
|
|
} from 'ontime-utils';
|
|
import {
|
|
CustomFields,
|
|
DatabaseModel,
|
|
EventCustomFields,
|
|
isOntimeBlock,
|
|
LogOrigin,
|
|
OntimeEvent,
|
|
OntimeRundown,
|
|
SupportedEvent,
|
|
TimerType,
|
|
TimeStrategy,
|
|
} from 'ontime-types';
|
|
|
|
import { parseAutomationSettings } from '../api-data/automation/automation.parser.js';
|
|
import { logger } from '../classes/Logger.js';
|
|
import { event as eventDef } from '../models/eventsDefinition.js';
|
|
|
|
import { makeString } from './parserUtils.js';
|
|
import { parseProject, parseRundown, parseSettings, parseUrlPresets, parseViewSettings } from './parserFunctions.js';
|
|
import { parseExcelDate } from './time.js';
|
|
|
|
export type ErrorEmitter = (message: string) => void;
|
|
export const EXCEL_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
export const JSON_MIME = 'application/json';
|
|
|
|
type ExcelData = Pick<DatabaseModel, 'rundown' | 'customFields'> & {
|
|
rundownMetadata: Record<string, { row: number; col: number }>;
|
|
};
|
|
|
|
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';
|
|
}
|
|
|
|
export function getCustomFieldData(
|
|
importMap: ImportMap,
|
|
existingCustomFields: CustomFields,
|
|
): {
|
|
customFields: CustomFields;
|
|
customFieldImportKeys: Record<keyof CustomFields, string>;
|
|
} {
|
|
const customFields = {};
|
|
const customFieldImportKeys = {};
|
|
for (const ontimeLabel in importMap.custom) {
|
|
const ontimeKey = customKeyFromLabel(ontimeLabel, existingCustomFields) ?? customFieldLabelToKey(ontimeLabel);
|
|
const importLabel = importMap.custom[ontimeLabel].toLowerCase();
|
|
const colour = ontimeKey in existingCustomFields ? existingCustomFields[ontimeKey].colour : '';
|
|
customFields[ontimeKey] = {
|
|
type: 'string',
|
|
colour,
|
|
label: ontimeLabel,
|
|
};
|
|
customFieldImportKeys[importLabel] = ontimeKey;
|
|
}
|
|
return { customFields, customFieldImportKeys };
|
|
}
|
|
|
|
/**
|
|
* @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
|
|
*/
|
|
export const parseExcel = (
|
|
excelData: unknown[][],
|
|
existingCustomFields: CustomFields,
|
|
options?: Partial<ImportMap>,
|
|
): ExcelData => {
|
|
const rundownMetadata = {};
|
|
const importMap: ImportMap = { ...defaultImportMap, ...options };
|
|
|
|
for (const [key, value] of Object.entries(importMap)) {
|
|
if (typeof value === 'string') {
|
|
importMap[key] = value.toLocaleLowerCase().trim();
|
|
}
|
|
}
|
|
|
|
const { customFields, customFieldImportKeys } = getCustomFieldData(importMap, existingCustomFields);
|
|
const rundown: OntimeRundown = [];
|
|
|
|
// 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 isPublicIndex: 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> = {};
|
|
|
|
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 };
|
|
},
|
|
|
|
[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.countToEnd]: (row: number, col: number) => {
|
|
countToEndIndex = col;
|
|
rundownMetadata['countToEnd'] = { row, col };
|
|
},
|
|
[importMap.isPublic]: (row: number, col: number) => {
|
|
isPublicIndex = col;
|
|
rundownMetadata['isPublic'] = { 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 event: any = {};
|
|
const eventCustomFields: EventCustomFields = {};
|
|
|
|
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 === 'block') {
|
|
event.type = SupportedEvent.Block;
|
|
} else if (maybeTimeType === '' || maybeTimeType === 'event' || isKnownTimerType(maybeTimeType)) {
|
|
event.type = SupportedEvent.Event;
|
|
event.timerType = validateTimerType(maybeTimeType);
|
|
} else {
|
|
// if it is not a block or a known type, we dont import it
|
|
return;
|
|
}
|
|
} else if (j === titleIndex) {
|
|
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) {
|
|
event.duration = parseExcelDate(column);
|
|
} else if (j === cueIndex) {
|
|
event.cue = makeString(column, '');
|
|
} else if (j === countToEndIndex) {
|
|
event.countToEnd = parseBooleanString(column);
|
|
} else if (j === isPublicIndex) {
|
|
event.isPublic = parseBooleanString(column);
|
|
} else if (j === skipIndex) {
|
|
event.skip = parseBooleanString(column);
|
|
} else if (j === notesIndex) {
|
|
event.note = makeString(column, '');
|
|
} else if (j === endActionIndex) {
|
|
event.endAction = validateEndAction(column);
|
|
} else if (j === timeWarningIndex) {
|
|
event.timeWarning = parseExcelDate(column);
|
|
} else if (j === timeDangerIndex) {
|
|
event.timeDanger = parseExcelDate(column);
|
|
} else if (j === colourIndex) {
|
|
event.colour = makeString(column, '');
|
|
} else if (j === entryIdIndex) {
|
|
event.id = encodeURIComponent(makeString(column, undefined));
|
|
} else if (j in customFieldIndexes) {
|
|
const importKey = customFieldIndexes[j];
|
|
const ontimeKey = customFieldImportKeys[importKey];
|
|
eventCustomFields[ontimeKey] = makeString(column, '');
|
|
} else {
|
|
// 2. if there is no flag, lets see if we know the field type
|
|
if (typeof column === 'string') {
|
|
// we cant deal with empty content
|
|
if (column.length === 0) {
|
|
continue;
|
|
}
|
|
const columnText = column.toLowerCase().trim();
|
|
|
|
// check if it is an ontime column
|
|
if (handlers[columnText]) {
|
|
handlers[columnText](rowIndex, j, undefined, undefined);
|
|
}
|
|
|
|
// check if it is a custom field
|
|
if (columnText in customFieldImportKeys) {
|
|
const ontimeKey = customFieldImportKeys[columnText];
|
|
handlers.custom(rowIndex, j, columnText, ontimeKey);
|
|
}
|
|
|
|
// else. we don't know how to handle this column
|
|
// just ignore it
|
|
}
|
|
}
|
|
}
|
|
|
|
// if any data was found in row, push to array
|
|
const keysFound = Object.keys(event).length + Object.keys(eventCustomFields).length;
|
|
if (keysFound > 0) {
|
|
// if it is a Block type drop all other filed
|
|
if (isOntimeBlock(event)) {
|
|
rundown.push({ type: event.type, id: event.id, title: event.title });
|
|
} else {
|
|
if (timerTypeIndex === null) {
|
|
event.timerType = TimerType.CountDown;
|
|
event.type = SupportedEvent.Event;
|
|
}
|
|
rundown.push({ ...event, custom: { ...eventCustomFields } });
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
rundown,
|
|
customFields,
|
|
rundownMetadata,
|
|
};
|
|
};
|
|
|
|
export type ParsingError = {
|
|
context: string;
|
|
message: string;
|
|
};
|
|
|
|
/**
|
|
* @description handles parsing of ontime project file
|
|
* @param {object} jsonData - project file to be parsed
|
|
* @returns {object} - parsed object
|
|
*/
|
|
export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): { data: DatabaseModel; errors: ParsingError[] } {
|
|
// we need to parse settings first to make sure the data is ours
|
|
// this may throw
|
|
const settings = parseSettings(jsonData);
|
|
|
|
const errors: ParsingError[] = [];
|
|
const makeEmitError = (context: string) => (message: string) => {
|
|
logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`);
|
|
errors.push({ context, message });
|
|
};
|
|
|
|
// we need to parse the custom fields first so they can be used in validating events
|
|
// TODO: can we improve the readability of the error?
|
|
const { rundown, customFields } = parseRundown(jsonData, makeEmitError('Rundown'));
|
|
|
|
const data: DatabaseModel = {
|
|
rundown,
|
|
project: parseProject(jsonData, makeEmitError('Project')),
|
|
settings,
|
|
viewSettings: parseViewSettings(jsonData, makeEmitError('View Settings')),
|
|
urlPresets: parseUrlPresets(jsonData, makeEmitError('URL Presets')),
|
|
customFields,
|
|
automation: parseAutomationSettings(jsonData),
|
|
};
|
|
|
|
return { data, errors };
|
|
}
|
|
|
|
/**
|
|
* Function infers strategy for a patch with only partial timer data
|
|
* @param end
|
|
* @param duration
|
|
* @param fallback
|
|
* @returns
|
|
*/
|
|
function inferStrategy(end: unknown, duration: unknown, fallback: TimeStrategy): TimeStrategy {
|
|
if (end && !duration) {
|
|
return TimeStrategy.LockEnd;
|
|
}
|
|
|
|
if (!end && duration) {
|
|
return TimeStrategy.LockDuration;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function createPatch(originalEvent: OntimeEvent, patchEvent: Partial<OntimeEvent>): OntimeEvent {
|
|
if (Object.keys(patchEvent).length === 0) {
|
|
return originalEvent;
|
|
}
|
|
|
|
const { timeStart, timeEnd, duration, timeStrategy } = validateTimes(
|
|
patchEvent?.timeStart ?? originalEvent.timeStart,
|
|
patchEvent?.timeEnd ?? originalEvent.timeEnd,
|
|
patchEvent?.duration ?? originalEvent.duration,
|
|
patchEvent?.timeStrategy ?? inferStrategy(patchEvent?.timeEnd, patchEvent?.duration, originalEvent.timeStrategy),
|
|
);
|
|
|
|
return {
|
|
id: originalEvent.id,
|
|
type: SupportedEvent.Event,
|
|
title: makeString(patchEvent.title, originalEvent.title),
|
|
timeStart,
|
|
timeEnd,
|
|
duration,
|
|
timeStrategy,
|
|
linkStart: validateLinkStart(patchEvent.linkStart, originalEvent.linkStart),
|
|
endAction: validateEndAction(patchEvent.endAction, originalEvent.endAction),
|
|
timerType: validateTimerType(patchEvent.timerType, originalEvent.timerType),
|
|
countToEnd: typeof patchEvent.countToEnd === 'boolean' ? patchEvent.countToEnd : originalEvent.countToEnd,
|
|
isPublic: typeof patchEvent.isPublic === 'boolean' ? patchEvent.isPublic : originalEvent.isPublic,
|
|
skip: typeof patchEvent.skip === 'boolean' ? patchEvent.skip : originalEvent.skip,
|
|
note: makeString(patchEvent.note, originalEvent.note),
|
|
colour: makeString(patchEvent.colour, originalEvent.colour),
|
|
delay: originalEvent.delay, // is regenerated if timer related data is changed
|
|
dayOffset: originalEvent.dayOffset, // is regenerated if timer related data is changed
|
|
gap: originalEvent.gap, // is regenerated if timer related data is changed
|
|
// short circuit empty string
|
|
cue: makeString(patchEvent.cue ?? null, originalEvent.cue),
|
|
revision: originalEvent.revision,
|
|
timeWarning: patchEvent.timeWarning ?? originalEvent.timeWarning,
|
|
timeDanger: patchEvent.timeDanger ?? originalEvent.timeDanger,
|
|
custom: { ...originalEvent.custom, ...patchEvent.custom },
|
|
triggers: patchEvent.triggers ?? originalEvent.triggers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @description Enforces formatting for events
|
|
* @param {object} eventArgs - attributes of event
|
|
* @param {number} eventIndex - can be a string when we pass the a suggested cue name
|
|
* @returns {object|null} - formatted object or null in case is invalid
|
|
*/
|
|
export const createEvent = (eventArgs: Partial<OntimeEvent>, eventIndex: number | string): OntimeEvent | null => {
|
|
if (Object.keys(eventArgs).length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const cue = typeof eventIndex === 'number' ? String(eventIndex + 1) : eventIndex;
|
|
|
|
const baseEvent = {
|
|
id: eventArgs?.id ?? generateId(),
|
|
cue,
|
|
...eventDef,
|
|
};
|
|
const event = createPatch(baseEvent, eventArgs);
|
|
return event;
|
|
};
|