mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 17:33:55 +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:
@@ -4,6 +4,8 @@ import { getErrorMessage } from 'ontime-utils';
|
||||
import express from 'express';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
import { getProjectCustomFields } from '../rundown/rundown.dao.js';
|
||||
import { createCustomField, editCustomField, deleteCustomField } from '../rundown/rundown.service.js';
|
||||
|
||||
@@ -11,37 +13,52 @@ import { validateCustomField, validateDeleteCustomField, validateEditCustomField
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
/**
|
||||
* Gets all the custom fields for the project
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response<CustomFields>) => {
|
||||
const customFields = getProjectCustomFields();
|
||||
res.status(200).json(customFields);
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new custom field
|
||||
*/
|
||||
router.post('/', validateCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const newFields = await createCustomField(req.body as CustomField);
|
||||
res.status(201).send(newFields);
|
||||
res.status(201).json(newFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifies the properties of an existing custom field
|
||||
*/
|
||||
router.put('/:key', validateEditCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const currentKey = req.params.key;
|
||||
const { colour, type, label } = req.body;
|
||||
const newFields = await editCustomField(currentKey, { label, colour, type });
|
||||
res.status(200).send(newFields);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const newFields = await editCustomField(currentKey, { label, colour, type }, projectRundowns);
|
||||
res.status(200).json(newFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes an existing custom field
|
||||
*/
|
||||
router.delete('/:key', validateDeleteCustomField, async (req: Request, res: Response<CustomFields | ErrorResponse>) => {
|
||||
try {
|
||||
const customFields = await deleteCustomField(req.params.key);
|
||||
res.status(200).send(customFields);
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
const customFields = await deleteCustomField(req.params.key, projectRundowns);
|
||||
res.status(200).json(customFields);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
|
||||
@@ -26,7 +26,6 @@ export function parseDatabaseModel(jsonData: Partial<DatabaseModel>): {
|
||||
errors: ParsingError[];
|
||||
migrated: boolean;
|
||||
} {
|
||||
//TODO: TEST THIS!!!!!!!
|
||||
let migrated = false;
|
||||
let migratedData = jsonData;
|
||||
if (v3.shouldUseThisMigration(jsonData)) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { router as urlPresetsRouter } from './url-presets/urlPresets.router.js';
|
||||
import { router as customFieldsRouter } from './custom-fields/customFields.router.js';
|
||||
import { router as dbRouter } from './db/db.router.js';
|
||||
import { router as projectRouter } from './project-data/projectData.router.js';
|
||||
import { router as rundownRouter } from './rundown/rundown.router.js';
|
||||
import { router as rundownsRouter } from './rundown/rundown.router.js';
|
||||
import { router as settingsRouter } from './settings/settings.router.js';
|
||||
import { router as sheetsRouter } from './sheets/sheets.router.js';
|
||||
import { router as excelRouter } from './excel/excel.router.js';
|
||||
@@ -20,7 +20,7 @@ appRouter.use('/automations', automationsRouter);
|
||||
appRouter.use('/custom-fields', customFieldsRouter);
|
||||
appRouter.use('/db', dbRouter);
|
||||
appRouter.use('/project', projectRouter);
|
||||
appRouter.use('/rundown', rundownRouter);
|
||||
appRouter.use('/rundowns', rundownsRouter);
|
||||
appRouter.use('/settings', settingsRouter);
|
||||
appRouter.use('/sheets', sheetsRouter);
|
||||
appRouter.use('/excel', excelRouter);
|
||||
@@ -30,7 +30,7 @@ appRouter.use('/view-settings', viewSettingsRouter);
|
||||
appRouter.use('/report', reportRouter);
|
||||
appRouter.use('/assets', assetsRouter);
|
||||
|
||||
//we don't want to redirect to react index when using api routes
|
||||
// we don't want to redirect to react index when using api routes
|
||||
appRouter.all('/*splat', (_req, res) => {
|
||||
res.status(404).send('data path not found');
|
||||
res.status(404).send('Unhandled request');
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy } from 'ontime-types';
|
||||
import { CustomFields, OntimeGroup, OntimeDelay, OntimeEvent, SupportedEntry, TimeStrategy, OntimeMilestone } from 'ontime-types';
|
||||
import { dayInMs, MILLIS_PER_HOUR, MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
makeOntimeGroup,
|
||||
makeOntimeDelay,
|
||||
makeCustomField,
|
||||
makeOntimeMilestone,
|
||||
} from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
import {
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
rundownMutation,
|
||||
} from '../rundown.dao.js';
|
||||
import { demoDb } from '../../../models/demoProject.js';
|
||||
import type { AssignedMap } from '../rundown.types.js';
|
||||
import { type ProcessedRundownMetadata } from '../rundown.parser.js';
|
||||
|
||||
const setRundownMock = vi.fn();
|
||||
@@ -554,10 +554,6 @@ describe('processRundown()', () => {
|
||||
});
|
||||
const initResult = processRundown(rundown, customProperties);
|
||||
expect(initResult.order.length).toBe(2);
|
||||
expect(initResult.assignedCustomFields).toMatchObject({
|
||||
lighting: ['1', '2'],
|
||||
sound: ['2'],
|
||||
});
|
||||
expect((initResult.entries['1'] as OntimeEvent).custom).toMatchObject({ lighting: 'event 1 lx' });
|
||||
expect((initResult.entries['2'] as OntimeEvent).custom).toMatchObject({
|
||||
lighting: 'event 2 lx',
|
||||
@@ -1742,22 +1738,26 @@ describe('customFieldMutation.renameUsages()', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const assigned: AssignedMap = {
|
||||
one: ['1', '2'],
|
||||
two: ['3'],
|
||||
};
|
||||
|
||||
customFieldMutation.renameUsages(rundown, assigned, 'one', 'new-one');
|
||||
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
|
||||
expect(rundown.entries).toMatchObject({
|
||||
'1': { id: '1', custom: { 'new-one': 'value1' } },
|
||||
'2': { id: '2', custom: { 'new-one': 'value2' } },
|
||||
'3': { id: '3', custom: { two: 'value3' } },
|
||||
});
|
||||
});
|
||||
|
||||
expect(assigned).toStrictEqual({
|
||||
'new-one': ['1', '2'],
|
||||
two: ['3'],
|
||||
it('renames usages inside groups and milestones', () => {
|
||||
const rundown = makeRundown({
|
||||
order: ['group', 'm1'],
|
||||
entries: {
|
||||
group: makeOntimeGroup({ id: 'group', entries: ['e1'] }),
|
||||
e1: makeOntimeEvent({ id: 'e1', parent: 'group', custom: { one: 'v' } }),
|
||||
m1: makeOntimeMilestone({ id: 'm1', custom: { two: 'keep' } }),
|
||||
},
|
||||
});
|
||||
customFieldMutation.renameUsages(rundown, 'one', 'new-one');
|
||||
expect((rundown.entries['e1'] as OntimeEvent).custom).toMatchObject({ 'new-one': 'v' });
|
||||
expect((rundown.entries['m1'] as OntimeMilestone).custom).toMatchObject({ two: 'keep' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1772,17 +1772,8 @@ describe('customFieldMutation.removeUsages()', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const assigned: AssignedMap = {
|
||||
one: ['1', '2'],
|
||||
two: ['3'],
|
||||
};
|
||||
|
||||
customFieldMutation.removeUsages(rundown, assigned, 'one');
|
||||
customFieldMutation.removeUsages(rundown, 'one');
|
||||
expect((rundown.entries['1'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||
expect((rundown.entries['2'] as OntimeEvent).custom).not.toHaveProperty('one');
|
||||
|
||||
expect(assigned).toStrictEqual({
|
||||
two: ['3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { SupportedEntry, OntimeEvent, OntimeGroup, Rundown, CustomFields } from
|
||||
import { defaultRundown } from '../../../models/dataModel.js';
|
||||
import { makeOntimeGroup, makeOntimeEvent, makeOntimeMilestone } from '../__mocks__/rundown.mocks.js';
|
||||
|
||||
import { parseRundowns, parseRundown, handleCustomField, addToCustomAssignment } from '../rundown.parser.js';
|
||||
import { parseRundowns, parseRundown, sanitiseCustomFields } from '../rundown.parser.js';
|
||||
|
||||
describe('parseRundowns()', () => {
|
||||
it('returns a default project rundown if nothing is given', () => {
|
||||
@@ -293,20 +293,8 @@ describe('parseRundown()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToCustomAssignment()', () => {
|
||||
it('adds given entry to assignedCustomFields', () => {
|
||||
const assignedCustomFields = {};
|
||||
|
||||
addToCustomAssignment('label1', 'eventId 1', assignedCustomFields);
|
||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1'] });
|
||||
|
||||
addToCustomAssignment('label1', 'eventId 2', assignedCustomFields);
|
||||
expect(assignedCustomFields).toStrictEqual({ label1: ['eventId 1', 'eventId 2'] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCustomField()', () => {
|
||||
it('creates a map of where custom fields are used', () => {
|
||||
describe('sanitiseCustomFields()', () => {
|
||||
it('deletes unused custom fields', () => {
|
||||
const customFields = {
|
||||
lighting: {
|
||||
type: 'text',
|
||||
@@ -327,13 +315,11 @@ describe('handleCustomField()', () => {
|
||||
linkStart: true,
|
||||
custom: {
|
||||
lighting: 'on',
|
||||
unknown: 'does-not-exist',
|
||||
},
|
||||
});
|
||||
const assignedCustomFields = {};
|
||||
|
||||
const result = handleCustomField(customFields, event, assignedCustomFields);
|
||||
expect(result).toBeUndefined();
|
||||
expect(assignedCustomFields).toStrictEqual({ lighting: ['2'] });
|
||||
sanitiseCustomFields(customFields, event);
|
||||
expect(event.custom).toStrictEqual({
|
||||
lighting: 'on',
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ import { customFieldLabelToKey, insertAtIndex } from 'ontime-utils';
|
||||
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
|
||||
import type { AssignedMap, CustomFieldsMetadata, RundownMetadata } from './rundown.types.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import {
|
||||
applyPatchToEntry,
|
||||
cloneGroup,
|
||||
@@ -67,15 +67,6 @@ let rundownMetadata: RundownMetadata = {
|
||||
flags: [],
|
||||
};
|
||||
|
||||
const customFieldsMetadata: CustomFieldsMetadata = {
|
||||
/**
|
||||
* Keep track of which custom fields are used.
|
||||
* This will be handy for when we delete custom fields
|
||||
* since we can clear the custom fields from every event where they are used
|
||||
*/
|
||||
assigned: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* The custom fields that are used in the project
|
||||
* Not unique to the loaded rundown
|
||||
@@ -89,7 +80,6 @@ export const getEntryWithId = (entryId: EntryId): OntimeEntry | undefined => cac
|
||||
|
||||
type Transaction = {
|
||||
customFields: CustomFields;
|
||||
customFieldsMetadata: Readonly<CustomFieldsMetadata>;
|
||||
rundown: Rundown;
|
||||
rundownMetadata: Readonly<RundownMetadata>;
|
||||
|
||||
@@ -136,13 +126,11 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
||||
const processedData = processRundown(rundown, projectCustomFields);
|
||||
// update the cache values
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||
processedData;
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||
customFieldsMetadata.assigned = assignedCustomFields;
|
||||
rundownMetadata = metadata;
|
||||
}
|
||||
}
|
||||
@@ -167,7 +155,6 @@ export function createTransaction(options: TransactionOptions): Transaction {
|
||||
|
||||
return {
|
||||
customFields,
|
||||
customFieldsMetadata,
|
||||
rundown,
|
||||
rundownMetadata,
|
||||
commit,
|
||||
@@ -564,6 +551,15 @@ export const rundownMutation = {
|
||||
ungroup,
|
||||
};
|
||||
|
||||
/**
|
||||
* Exposes a way to update a rundown which is not active
|
||||
*/
|
||||
export function updateBackgroundRundown(rundownId: string, rundown: Rundown) {
|
||||
setImmediate(async () => {
|
||||
await getDataProvider().setRundown(rundownId, rundown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new custom field to the object and returns it
|
||||
*/
|
||||
@@ -603,51 +599,29 @@ function customFieldRemove(customFields: CustomFields, key: CustomFieldKey) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a custom field key in all the rundown entries that use it
|
||||
* Iterates through all entries of a rundown and renames a custom field
|
||||
*/
|
||||
function customFieldRenameUsages(
|
||||
rundown: Rundown,
|
||||
assigned: AssignedMap,
|
||||
oldKey: CustomFieldKey,
|
||||
newKey: CustomFieldKey,
|
||||
) {
|
||||
const usages = assigned[oldKey];
|
||||
|
||||
// iterate through all the entries that use the custom field
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const entryId = usages[i];
|
||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
||||
|
||||
// copy the data a new key and delete the old key
|
||||
entry.custom[newKey] = entry.custom[oldKey];
|
||||
delete entry.custom[oldKey];
|
||||
}
|
||||
|
||||
// update assignment
|
||||
assigned[newKey] = [...assigned[oldKey]];
|
||||
delete assigned[oldKey];
|
||||
function customFieldRenameUsages(rundown: Rundown, oldKey: CustomFieldKey, newKey: CustomFieldKey) {
|
||||
Object.keys(rundown.entries).forEach((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if ('custom' in entry && entry.custom[oldKey]) {
|
||||
// copy the data a new key and delete the old key
|
||||
entry.custom[newKey] = entry.custom[oldKey];
|
||||
delete entry.custom[oldKey];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes data for a custom field from all the entries that use it
|
||||
* Iterates through all entries of a rundown and removes data associated with a custom field
|
||||
*/
|
||||
function customFieldRemoveUsages(rundown: Rundown, assigned: AssignedMap, key: CustomFieldKey) {
|
||||
const usages = assigned[key];
|
||||
if (!usages) {
|
||||
return;
|
||||
}
|
||||
|
||||
// iterate through all the entries that use the custom field
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const entryId = usages[i];
|
||||
const entry = rundown.entries[entryId] as OntimeEvent;
|
||||
|
||||
// delete the custom field entry
|
||||
delete entry.custom[key];
|
||||
}
|
||||
|
||||
// update assignment
|
||||
delete assigned[key];
|
||||
function customFieldRemoveUsages(rundown: Rundown, key: CustomFieldKey) {
|
||||
Object.keys(rundown.entries).forEach((entryId) => {
|
||||
const entry = rundown.entries[entryId];
|
||||
if ('custom' in entry && entry.custom[key]) {
|
||||
delete entry.custom[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const customFieldMutation = {
|
||||
@@ -672,13 +646,11 @@ export function init(initialRundown: Readonly<Rundown>, initialCustomFields: Rea
|
||||
projectCustomFields = customFields;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- we are not interested in the iteration data
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, assignedCustomFields, ...metadata } =
|
||||
processedData;
|
||||
const { previousEvent, latestEvent, previousEntry, entries, order, ...metadata } = processedData;
|
||||
cachedRundown.entries = entries;
|
||||
cachedRundown.order = order;
|
||||
cachedRundown.flatOrder = metadata.flatEntryOrder;
|
||||
cachedRundown.revision = rundown.revision;
|
||||
customFieldsMetadata.assigned = assignedCustomFields;
|
||||
rundownMetadata = metadata;
|
||||
|
||||
// defer writing to the database
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
RundownEntries,
|
||||
isPlayableEvent,
|
||||
isOntimeMilestone,
|
||||
OntimeMilestone,
|
||||
OntimeGroup,
|
||||
} from 'ontime-types';
|
||||
import { isObjectEmpty, generateId, getLinkedTimes, getTimeFrom, isNewLatest } from 'ontime-utils';
|
||||
|
||||
@@ -172,40 +174,16 @@ export function parseRundown(
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to add an entry, mutates given assignedCustomFields in place
|
||||
* @param label
|
||||
* @param eventId
|
||||
* Ensures that custom fields have references
|
||||
* If a field is exists in the entry but not in the project customFields, it is deleted
|
||||
* Mutates the given event in place
|
||||
*/
|
||||
export function addToCustomAssignment(
|
||||
key: CustomFieldKey,
|
||||
eventId: EntryId,
|
||||
assignedCustomFields: Record<string, string[]>,
|
||||
) {
|
||||
if (!Array.isArray(assignedCustomFields[key])) {
|
||||
assignedCustomFields[key] = [];
|
||||
}
|
||||
assignedCustomFields[key].push(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps track of which custom fields are assigned to which events
|
||||
* Mutates the given assignedCustomFields in place
|
||||
* If a field is referenced but is not in the customFields map, it is deleted
|
||||
*/
|
||||
export function handleCustomField(
|
||||
customFields: CustomFields,
|
||||
event: OntimeEvent,
|
||||
assignedCustomFields: Record<CustomFieldKey, EntryId[]>,
|
||||
) {
|
||||
for (const field in event.custom) {
|
||||
if (field in customFields) {
|
||||
// add field to assignment map
|
||||
addToCustomAssignment(field, event.id, assignedCustomFields);
|
||||
} else {
|
||||
// delete data if it is not declared in project level custom fields
|
||||
delete event.custom[field];
|
||||
}
|
||||
export function sanitiseCustomFields(customFields: CustomFields, entry: OntimeEvent | OntimeMilestone | OntimeGroup) {
|
||||
for (const field in entry.custom) {
|
||||
if (field in customFields) continue;
|
||||
delete entry.custom[field];
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export type ProcessedRundownMetadata = RundownMetadata & {
|
||||
@@ -292,7 +270,7 @@ function processEntry<T extends OntimeEntry>(
|
||||
}
|
||||
|
||||
// 2. handle custom fields - mutates currentEntry
|
||||
handleCustomField(customFields, currentEntry, processedData.assignedCustomFields);
|
||||
sanitiseCustomFields(customFields, currentEntry);
|
||||
|
||||
processedData.totalDays += calculateDayOffset(currentEntry, processedData.previousEvent);
|
||||
currentEntry.dayOffset = processedData.totalDays;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ErrorResponse, MessageResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { ErrorResponse, OntimeEntry, ProjectRundownsList, Rundown } from 'ontime-types';
|
||||
import { generateId, getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import express from 'express';
|
||||
@@ -14,40 +14,36 @@ import {
|
||||
deleteEntries,
|
||||
editEntry,
|
||||
groupEntries,
|
||||
initRundown,
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
} from './rundown.service.js';
|
||||
import {
|
||||
rundownArrayOfIds,
|
||||
rundownBatchPutValidator,
|
||||
entryBatchPutValidator,
|
||||
entryPostValidator,
|
||||
rundownPostValidator,
|
||||
rundownPutValidator,
|
||||
rundownReorderValidator,
|
||||
rundownSwapValidator,
|
||||
entryPutValidator,
|
||||
entryReorderValidator,
|
||||
entrySwapValidator,
|
||||
validateRundownMutation,
|
||||
} from './rundown.validation.js';
|
||||
import { paramsWithId } from '../validation-utils/validationFunction.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import { defaultRundown } from '../../models/dataModel.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
|
||||
export const router = express.Router();
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
/**
|
||||
* Returns all rundowns in the project
|
||||
*/
|
||||
router.get('/', async (_req: Request, res: Response<ProjectRundownsList>) => {
|
||||
const rundown = getCurrentRundown();
|
||||
|
||||
// TODO: we currently make a project with only the current rundown
|
||||
res.json({
|
||||
loaded: rundown.id,
|
||||
rundowns: [
|
||||
{
|
||||
id: rundown.id,
|
||||
title: rundown.title,
|
||||
numEntries: rundown.order.length,
|
||||
revision: rundown.revision,
|
||||
},
|
||||
],
|
||||
});
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -58,113 +54,275 @@ router.get('/current', async (_req: Request, res: Response<Rundown>) => {
|
||||
res.json(rundown);
|
||||
});
|
||||
|
||||
router.post('/', rundownPostValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
/**
|
||||
* Loads a given rundown
|
||||
*/
|
||||
router.post('/:id/load', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const newEvent = await addEntry(req.body);
|
||||
res.status(201).send(newEvent);
|
||||
// maybe the rundown is already loaded
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
return;
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
const rundown = dataProvider.getRundown(req.params.id);
|
||||
const customField = dataProvider.getCustomFields();
|
||||
await initRundown(rundown, customField);
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/', rundownPutValidator, async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
/**
|
||||
* Creates a new rundown
|
||||
*/
|
||||
router.post('/', rundownPostValidator, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const event = await editEntry(req.body);
|
||||
res.status(200).send(event);
|
||||
const id = generateId();
|
||||
await getDataProvider().setRundown(id, { ...defaultRundown, id, title: req.body.title });
|
||||
|
||||
const projectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(201).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(projectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/batch', rundownBatchPutValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
/**
|
||||
* Deletes a rundown if not loaded
|
||||
*/
|
||||
router.delete('/:id', paramsWithId, async (req: Request, res: Response<ProjectRundownsList | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
||||
res.status(200).send(rundown);
|
||||
if (req.params.id === getCurrentRundown().id) {
|
||||
res.status(400).send({ message: 'Cannot delete loaded rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dataProvider = getDataProvider();
|
||||
const projectRundowns = dataProvider.getProjectRundowns();
|
||||
|
||||
if (Object.keys(projectRundowns).length <= 1) {
|
||||
// might never hit this as it is likely covered by the case of trying to delete the loaded rundown
|
||||
res.status(400).send({ message: 'Cannot delete the last rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
await dataProvider.deleteRundown(req.params.id);
|
||||
const newProjectRundowns = getDataProvider().getProjectRundowns();
|
||||
res.status(200).json({ loaded: getCurrentRundown().id, rundowns: normalisedToRundownArray(newProjectRundowns) });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/reorder', rundownReorderValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const { entryId, destinationId, order } = req.body;
|
||||
const newRundown = await reorderEntry(entryId, destinationId, order);
|
||||
res.status(200).send(newRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
// #endregion operations on project rundowns ======================
|
||||
|
||||
router.patch('/swap', rundownSwapValidator, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await swapEvents(req.body.from, req.body.to);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
router.patch('/applydelay/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const newRundown = await applyDelay(req.params.id);
|
||||
res.status(200).send(newRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Creates a new entry in a given rundown
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/entry',
|
||||
entryPostValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
try {
|
||||
const newEvent = await addEntry(req.body);
|
||||
res.status(201).send(newEvent);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post('/clone/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const newRundown = await cloneEntry(req.params.id);
|
||||
res.status(200).send(newRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Edits an entry in a given rundown
|
||||
*/
|
||||
router.put(
|
||||
'/:rundownId/entry',
|
||||
entryPutValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<OntimeEntry | ErrorResponse>) => {
|
||||
try {
|
||||
const event = await editEntry(req.body);
|
||||
res.status(200).send(event);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post('/group', rundownArrayOfIds, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const newRundown = await groupEntries(req.body.ids);
|
||||
res.status(200).send(newRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Edits an entry in a given rundown
|
||||
*/
|
||||
router.put(
|
||||
'/:rundownId/batch',
|
||||
entryBatchPutValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await batchEditEntries(req.body.ids, req.body.data);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post('/ungroup/:id', paramsWithId, async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const newRundown = await ungroupEntries(req.params.id);
|
||||
res.status(200).send(newRundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Reorders two entries in a rundown
|
||||
*/
|
||||
router.patch(
|
||||
'/:rundownId/reorder',
|
||||
entryReorderValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const { entryId, destinationId, order } = req.body;
|
||||
const rundown = await reorderEntry(entryId, destinationId, order);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/', rundownArrayOfIds, async (req: Request, res: Response<MessageResponse | ErrorResponse>) => {
|
||||
try {
|
||||
await deleteEntries(req.body.ids);
|
||||
res.status(204).send({ message: 'Events deleted' });
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Applies a delay into the schedule
|
||||
*/
|
||||
router.patch(
|
||||
'/:rundownId/applydelay/:id',
|
||||
paramsWithId,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await applyDelay(req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/all', async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteAllEntries();
|
||||
res.status(204).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
});
|
||||
/**
|
||||
* Swaps data between two Ontime events
|
||||
*/
|
||||
router.patch(
|
||||
'/:rundownId/swap',
|
||||
entrySwapValidator,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await swapEvents(req.body.from, req.body.to);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Clones the contents of an entry into a new one
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/clone/:id',
|
||||
paramsWithId,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await cloneEntry(req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a group out of a list of entries
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/group',
|
||||
rundownArrayOfIds,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await groupEntries(req.body.ids);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Dissolves a group by moving its children to the main rundown
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/ungroup/:id',
|
||||
paramsWithId,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await ungroupEntries(req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
*/
|
||||
router.delete(
|
||||
'/:rundownId/entries',
|
||||
rundownArrayOfIds,
|
||||
validateRundownMutation,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteEntries(req.body.ids);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes all entries in a given rundown
|
||||
*/
|
||||
router.delete(
|
||||
'/:rundownId/all',
|
||||
validateRundownMutation,
|
||||
async (_req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await deleteAllEntries();
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
@@ -12,16 +12,26 @@ import {
|
||||
PatchWithId,
|
||||
RefetchKey,
|
||||
Rundown,
|
||||
LogOrigin,
|
||||
ProjectRundowns,
|
||||
} from 'ontime-types';
|
||||
import { customFieldLabelToKey } from 'ontime-utils';
|
||||
|
||||
import { updateRundownData } from '../../stores/runtimeState.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
|
||||
import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js';
|
||||
import {
|
||||
createTransaction,
|
||||
customFieldMutation,
|
||||
rundownCache,
|
||||
rundownMutation,
|
||||
updateBackgroundRundown,
|
||||
} from './rundown.dao.js';
|
||||
import type { RundownMetadata } from './rundown.types.js';
|
||||
import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js';
|
||||
import { sendRefetch } from '../../adapters/WebsocketAdapter.js';
|
||||
import { setLastLoadedRundown } from '../../services/app-state-service/AppStateService.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
|
||||
/**
|
||||
* creates a new entry with given data
|
||||
@@ -244,7 +254,7 @@ export async function deleteAllEntries(): Promise<Rundown> {
|
||||
* Handles moving across root orders (a group order and top level order)
|
||||
* @throws if entryId or destinationId not found
|
||||
*/
|
||||
export function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||
export async function reorderEntry(entryId: EntryId, destinationId: EntryId, order: 'before' | 'after' | 'insert') {
|
||||
const { rundown, commit } = createTransaction({ mutableRundown: true, mutableCustomFields: false });
|
||||
|
||||
// check that both entries exist
|
||||
@@ -383,8 +393,8 @@ export async function groupEntries(entryIds: EntryId[]): Promise<Rundown> {
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we dont need to notify the timer since the grouping does not affect the runtime
|
||||
notifyChanges(rundownMetadata, revision, { external: true });
|
||||
// we need to notify the timer since we might be grouping a running event
|
||||
notifyChanges(rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
@@ -454,8 +464,12 @@ export async function createCustomField(customField: CustomField): Promise<Custo
|
||||
* @throws if the label is missing or invalid
|
||||
* @throws if the new label already exists
|
||||
*/
|
||||
export async function editCustomField(key: CustomFieldKey, newField: Partial<CustomField>): Promise<CustomFields> {
|
||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
||||
export async function editCustomField(
|
||||
key: CustomFieldKey,
|
||||
newField: Partial<CustomField>,
|
||||
projectRundowns: ProjectRundowns,
|
||||
): Promise<CustomFields> {
|
||||
const { customFields, rundown, commit } = createTransaction({
|
||||
mutableRundown: true,
|
||||
mutableCustomFields: true,
|
||||
});
|
||||
@@ -472,14 +486,22 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
||||
|
||||
const { oldKey, newKey } = customFieldMutation.edit(customFields, key, existingField, newField);
|
||||
|
||||
// if key has changed
|
||||
// if key has changed ...
|
||||
if (oldKey !== newKey) {
|
||||
// 1. delete the old key
|
||||
customFieldMutation.remove(customFields, oldKey);
|
||||
if (oldKey in customFieldsMetadata.assigned) {
|
||||
// 2. reassign references
|
||||
customFieldMutation.renameUsages(rundown, customFieldsMetadata.assigned, oldKey, newKey);
|
||||
// ... reassign references in the active rundown
|
||||
customFieldMutation.renameUsages(rundown, oldKey, newKey);
|
||||
|
||||
// ... reassign references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
customFieldMutation.renameUsages(backgroundRundown, oldKey, newKey);
|
||||
updateBackgroundRundown(rundown.id, backgroundRundown);
|
||||
}
|
||||
}
|
||||
|
||||
// ... delete the old key
|
||||
customFieldMutation.remove(customFields, oldKey);
|
||||
}
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
@@ -487,6 +509,7 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
@@ -496,8 +519,8 @@ export async function editCustomField(key: CustomFieldKey, newField: Partial<Cus
|
||||
/**
|
||||
* Deletes an existing custom field
|
||||
*/
|
||||
export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFields> {
|
||||
const { customFields, customFieldsMetadata, rundown, commit } = createTransaction({
|
||||
export async function deleteCustomField(key: CustomFieldKey, projectRundowns: ProjectRundowns): Promise<CustomFields> {
|
||||
const { customFields, rundown, commit } = createTransaction({
|
||||
mutableRundown: true,
|
||||
mutableCustomFields: true,
|
||||
});
|
||||
@@ -505,16 +528,27 @@ export async function deleteCustomField(key: CustomFieldKey): Promise<CustomFiel
|
||||
return customFields;
|
||||
}
|
||||
|
||||
customFieldMutation.remove(customFields, key);
|
||||
if (key in customFieldsMetadata.assigned) {
|
||||
customFieldMutation.removeUsages(rundown, customFieldsMetadata.assigned, key);
|
||||
// remove references in the active rundown
|
||||
customFieldMutation.removeUsages(rundown, key);
|
||||
|
||||
// remove references in the background rundowns
|
||||
for (const rundownId of Object.keys(projectRundowns)) {
|
||||
if (rundownId !== rundown.id) {
|
||||
const backgroundRundown = structuredClone(projectRundowns[rundownId]);
|
||||
customFieldMutation.removeUsages(backgroundRundown, key);
|
||||
updateBackgroundRundown(rundown.id, backgroundRundown);
|
||||
}
|
||||
}
|
||||
|
||||
// delete the old key
|
||||
customFieldMutation.remove(customFields, key);
|
||||
|
||||
// the custom fields have been removed and there is no processing to be done
|
||||
const { rundownMetadata, revision, customFields: resultCustomFields } = commit(false);
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
sendRefetch(RefetchKey.CustomFields);
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true });
|
||||
});
|
||||
|
||||
@@ -565,14 +599,20 @@ function notifyChanges(rundownMetadata: RundownMetadata, revision: number, optio
|
||||
* Sets a new rundown in the cache
|
||||
* and marks it as the currently loaded one
|
||||
*/
|
||||
export async function initRundown(rundown: Readonly<Rundown>, customFields: Readonly<CustomFields>) {
|
||||
export async function initRundown(
|
||||
rundown: Readonly<Rundown>,
|
||||
customFields: Readonly<CustomFields>,
|
||||
reload: boolean = false,
|
||||
) {
|
||||
const { rundownMetadata, revision } = rundownCache.init(rundown, customFields);
|
||||
|
||||
logger.info(LogOrigin.Server, `Switch to rundown: ${rundown.id}`);
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// notify timer of change
|
||||
setImmediate(() => {
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload: true });
|
||||
notifyChanges(rundownMetadata, revision, { timer: true, external: true, reload });
|
||||
setLastLoadedRundown(rundown.id).catch((error) => {
|
||||
logger.error(LogOrigin.Server, `Failed to persist last loaded rundown: ${error}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CustomFieldKey, EntryId, MaybeNumber } from 'ontime-types';
|
||||
import { EntryId, MaybeNumber } from 'ontime-types';
|
||||
|
||||
export type RundownMetadata = {
|
||||
totalDelay: number;
|
||||
@@ -12,8 +12,3 @@ export type RundownMetadata = {
|
||||
flatEntryOrder: EntryId[]; // flat order of entries
|
||||
flags: EntryId[]; // flat order of flagged entries
|
||||
};
|
||||
|
||||
export type AssignedMap = Record<CustomFieldKey, EntryId[]>;
|
||||
export type CustomFieldsMetadata = {
|
||||
assigned: AssignedMap;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
Rundown,
|
||||
SupportedEntry,
|
||||
TimeStrategy,
|
||||
ProjectRundown,
|
||||
ProjectRundowns,
|
||||
} from 'ontime-types';
|
||||
import {
|
||||
dayInMs,
|
||||
@@ -506,3 +508,12 @@ export function getTimedIndexFromPlayableIndex(metadata: RundownMetadata, index:
|
||||
const timedIndex = metadata.timedEventOrder.findIndex((id) => id === playableId);
|
||||
return timedIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* converts a project rundowns map into an array of rundowns
|
||||
*/
|
||||
export function normalisedToRundownArray(rundowns: ProjectRundowns): ProjectRundown[] {
|
||||
return Object.values(rundowns).map(({ id, flatOrder, title, revision }) => {
|
||||
return { id, numEntries: flatOrder.length, title, revision };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { body, param } from 'express-validator';
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
|
||||
export const rundownPostValidator = [
|
||||
import { requestValidationFunction } from '../validation-utils/validationFunction.js';
|
||||
import { getCurrentRundown } from './rundown.dao.js';
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
export const rundownPostValidator = [body('title').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
/**
|
||||
* Middleware prevents mutating a rundown that is not selected
|
||||
* This allows our service to still only handle the current rundown
|
||||
*
|
||||
* This would need to be removed in favour or rundown selection if we would like
|
||||
* to implement the mutation of background rundowns
|
||||
*/
|
||||
export async function validateRundownMutation(req: Request, res: Response, next: NextFunction) {
|
||||
const { rundownId } = req.params;
|
||||
|
||||
try {
|
||||
if (getCurrentRundown().id !== rundownId) {
|
||||
res.status(404).json({ message: 'Cannot mutate not selected rundown' });
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(404).json({ message: 'Rundown not found' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export const entryPostValidator = [
|
||||
body('type').isString().isIn(['event', 'delay', 'group', 'milestone']),
|
||||
body('after').optional().isString(),
|
||||
body('before').optional().isString(),
|
||||
@@ -9,9 +42,9 @@ export const rundownPostValidator = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const rundownPutValidator = [body('id').isString().notEmpty(), requestValidationFunction];
|
||||
export const entryPutValidator = [body('id').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
export const rundownBatchPutValidator = [
|
||||
export const entryBatchPutValidator = [
|
||||
body('data').isObject(),
|
||||
body('ids').isArray().notEmpty(),
|
||||
body('ids.*').isString(),
|
||||
@@ -19,7 +52,7 @@ export const rundownBatchPutValidator = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const rundownReorderValidator = [
|
||||
export const entryReorderValidator = [
|
||||
body('entryId').isString().notEmpty(),
|
||||
body('destinationId').isString().notEmpty(),
|
||||
body('order').isIn(['before', 'after', 'insert']),
|
||||
@@ -27,7 +60,7 @@ export const rundownReorderValidator = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const rundownSwapValidator = [
|
||||
export const entrySwapValidator = [
|
||||
body('from').isString().notEmpty(),
|
||||
body('to').isString().notEmpty(),
|
||||
|
||||
@@ -42,3 +75,5 @@ export const rundownArrayOfIds = [
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
@@ -5,7 +5,7 @@ import { publicDir } from '../../setup/index.js';
|
||||
import { socket } from '../../adapters/WebsocketAdapter.js';
|
||||
import { getLastRequest } from '../../api-integration/integration.controller.js';
|
||||
import { getCurrentProject } from '../../services/project-service/ProjectService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/RuntimeService.js';
|
||||
import { runtimeService } from '../../services/runtime-service/runtime.service.js';
|
||||
import { getNetworkInterfaces } from '../../utils/network.js';
|
||||
import { getTimezoneLabel } from '../../utils/time.js';
|
||||
import { password, routerPrefix } from '../../externals.js';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { param, validationResult } from 'express-validator';
|
||||
|
||||
export const paramsWithId = [param('id').isString().trim().notEmpty(), requestValidationFunction];
|
||||
|
||||
// #region operations on project rundowns =========================
|
||||
|
||||
/**
|
||||
* Runs validation and any error are sent with status 422
|
||||
*/
|
||||
@@ -31,4 +35,7 @@ export function requestValidationFunctionWithFile(req: Request, res: Response, n
|
||||
next();
|
||||
}
|
||||
|
||||
export const paramsWithId = [param('id').isString().trim().notEmpty(), requestValidationFunction];
|
||||
// #endregion operations on project rundowns ======================
|
||||
// #region operations on rundown entries ==========================
|
||||
|
||||
// #endregion operations on rundown entries =======================
|
||||
|
||||
Reference in New Issue
Block a user