* 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:
Carlos Valente
2025-09-03 15:50:20 +02:00
committed by GitHub
parent 3c41b40c5f
commit ae15f3cdc5
102 changed files with 2950 additions and 2104 deletions
@@ -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 });
-1
View File
@@ -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',
});
});
});
+99 -200
View File
@@ -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 };
}
+4 -4
View File
@@ -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',
});
+30 -58
View File
@@ -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;
+260 -102
View File
@@ -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 =======================